Skip to main content

osp_cli/ui/messages/
mod.rs

1//! Buffered user-facing messages and their canonical rendering helpers.
2
3mod render;
4#[cfg(test)]
5mod sink;
6
7use super::text::visible_inline_text;
8use crate::config::ResolvedConfig;
9use crate::ui::section_chrome::RuledSectionPolicy;
10use crate::ui::section_chrome::SectionFrameStyle;
11use crate::ui::settings::{RenderProfile, RenderSettings, resolve_settings};
12use crate::ui::style::{StyleOverrides, StyleToken, ThemeStyler};
13use crate::ui::theme::ThemeDefinition;
14
15pub(crate) use render::render_messages_with_styler_and_chrome;
16pub(crate) use render::{
17    MessageChrome, MessageFrameStyle, MessageRenderOptions, MessageRulePolicy,
18    render_messages_with_styler_from_config,
19};
20
21/// Severity level for buffered UI messages.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum MessageLevel {
24    Error,
25    Warning,
26    Success,
27    Info,
28    Trace,
29}
30
31impl MessageLevel {
32    pub fn parse(value: &str) -> Option<Self> {
33        match value.trim().to_ascii_lowercase().as_str() {
34            "error" => Some(MessageLevel::Error),
35            "warning" | "warn" => Some(MessageLevel::Warning),
36            "success" => Some(MessageLevel::Success),
37            "info" => Some(MessageLevel::Info),
38            "trace" => Some(MessageLevel::Trace),
39            _ => None,
40        }
41    }
42
43    pub fn title(self) -> &'static str {
44        match self {
45            MessageLevel::Error => "Errors",
46            MessageLevel::Warning => "Warnings",
47            MessageLevel::Success => "Success",
48            MessageLevel::Info => "Info",
49            MessageLevel::Trace => "Trace",
50        }
51    }
52
53    fn as_rank(self) -> i8 {
54        match self {
55            MessageLevel::Error => 0,
56            MessageLevel::Warning => 1,
57            MessageLevel::Success => 2,
58            MessageLevel::Info => 3,
59            MessageLevel::Trace => 4,
60        }
61    }
62
63    pub fn as_env_str(self) -> &'static str {
64        match self {
65            MessageLevel::Error => "error",
66            MessageLevel::Warning => "warning",
67            MessageLevel::Success => "success",
68            MessageLevel::Info => "info",
69            MessageLevel::Trace => "trace",
70        }
71    }
72
73    fn from_rank(rank: i8) -> Self {
74        match rank {
75            i8::MIN..=0 => MessageLevel::Error,
76            1 => MessageLevel::Warning,
77            2 => MessageLevel::Success,
78            3 => MessageLevel::Info,
79            _ => MessageLevel::Trace,
80        }
81    }
82
83    pub(crate) fn ordered() -> impl Iterator<Item = Self> {
84        [
85            MessageLevel::Error,
86            MessageLevel::Warning,
87            MessageLevel::Success,
88            MessageLevel::Info,
89            MessageLevel::Trace,
90        ]
91        .into_iter()
92    }
93
94    pub(crate) fn style_token(self) -> StyleToken {
95        match self {
96            MessageLevel::Error => StyleToken::Error,
97            MessageLevel::Warning => StyleToken::Warning,
98            MessageLevel::Success => StyleToken::Success,
99            MessageLevel::Info => StyleToken::Info,
100            MessageLevel::Trace => StyleToken::Trace,
101        }
102    }
103}
104
105/// Layout style used when rendering buffered messages.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum MessageLayout {
108    Minimal,
109    Plain,
110    Compact,
111    Grouped,
112}
113
114impl MessageLayout {
115    pub fn parse(value: &str) -> Option<Self> {
116        match value.trim().to_ascii_lowercase().as_str() {
117            "minimal" | "austere" => Some(Self::Minimal),
118            "plain" | "none" => Some(Self::Plain),
119            "compact" => Some(Self::Compact),
120            "grouped" | "full" => Some(Self::Grouped),
121            _ => None,
122        }
123    }
124}
125
126/// A single UI message with its associated severity.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct UiMessage {
129    pub level: MessageLevel,
130    pub text: String,
131}
132
133/// In-memory buffer for messages collected during command execution.
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
135pub struct MessageBuffer {
136    entries: Vec<UiMessage>,
137}
138
139/// Options for rendering grouped message output.
140#[derive(Debug, Clone)]
141pub struct GroupedRenderOptions<'a> {
142    pub max_level: MessageLevel,
143    pub color: bool,
144    pub unicode: bool,
145    pub width: Option<usize>,
146    pub theme: &'a ThemeDefinition,
147    pub layout: MessageLayout,
148    pub chrome_frame: SectionFrameStyle,
149    pub style_overrides: StyleOverrides,
150}
151
152impl MessageBuffer {
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    pub fn push<T: Into<String>>(&mut self, level: MessageLevel, text: T) {
158        self.push_message(UiMessage::new(level, text));
159    }
160
161    pub(crate) fn push_message(&mut self, message: UiMessage) {
162        self.entries.push(message);
163    }
164
165    pub fn error<T: Into<String>>(&mut self, text: T) {
166        self.push(MessageLevel::Error, text);
167    }
168
169    pub fn warning<T: Into<String>>(&mut self, text: T) {
170        self.push(MessageLevel::Warning, text);
171    }
172
173    pub fn success<T: Into<String>>(&mut self, text: T) {
174        self.push(MessageLevel::Success, text);
175    }
176
177    pub fn info<T: Into<String>>(&mut self, text: T) {
178        self.push(MessageLevel::Info, text);
179    }
180
181    pub fn trace<T: Into<String>>(&mut self, text: T) {
182        self.push(MessageLevel::Trace, text);
183    }
184
185    pub fn is_empty(&self) -> bool {
186        self.entries.is_empty()
187    }
188
189    pub fn entries(&self) -> &[UiMessage] {
190        &self.entries
191    }
192
193    pub(crate) fn entries_for_level(
194        &self,
195        level: MessageLevel,
196    ) -> impl Iterator<Item = &UiMessage> {
197        self.entries
198            .iter()
199            .filter(move |entry| entry.level == level)
200    }
201
202    pub fn render_grouped(&self, max_level: MessageLevel) -> String {
203        let theme = crate::ui::theme::resolve_theme("plain");
204        self.render_grouped_styled(
205            max_level,
206            false,
207            false,
208            None,
209            &theme,
210            MessageLayout::Grouped,
211        )
212    }
213
214    pub fn render_grouped_styled(
215        &self,
216        max_level: MessageLevel,
217        color: bool,
218        unicode: bool,
219        width: Option<usize>,
220        theme: &ThemeDefinition,
221        layout: MessageLayout,
222    ) -> String {
223        self.render_grouped_with_options(GroupedRenderOptions {
224            max_level,
225            color,
226            unicode,
227            width,
228            theme,
229            layout,
230            chrome_frame: default_message_chrome_frame(layout),
231            style_overrides: StyleOverrides::default(),
232        })
233    }
234
235    pub fn render_grouped_with_options(&self, options: GroupedRenderOptions<'_>) -> String {
236        if self.is_empty() {
237            return String::new();
238        }
239
240        let styler = ThemeStyler::new(options.color, options.theme, &options.style_overrides);
241        render::render_messages_with_styler_and_chrome(
242            self,
243            render::MessageRenderOptions {
244                max_level: options.max_level,
245                layout: options.layout,
246            },
247            &styler,
248            render::MessageChrome {
249                frame_style: message_frame_style(options.chrome_frame),
250                ruled_policy: render::MessageRulePolicy::PerSection,
251                unicode: options.unicode,
252                width: options.width,
253            },
254        )
255    }
256}
257
258pub(crate) fn message_layout_from_config(config: &ResolvedConfig) -> MessageLayout {
259    config
260        .get_string("ui.messages.layout")
261        .and_then(MessageLayout::parse)
262        .unwrap_or(MessageLayout::Grouped)
263}
264
265pub(crate) fn render_messages_from_settings(
266    config: &ResolvedConfig,
267    settings: &RenderSettings,
268    messages: &MessageBuffer,
269    verbosity: MessageLevel,
270) -> String {
271    let resolved = resolve_settings(settings, RenderProfile::Normal);
272    let styler = ThemeStyler::new(resolved.color, &resolved.theme, &resolved.style_overrides);
273    render_messages_with_styler_from_config(
274        messages,
275        config,
276        verbosity,
277        &styler,
278        MessageChrome {
279            frame_style: match settings.chrome_frame {
280                SectionFrameStyle::None => MessageFrameStyle::None,
281                SectionFrameStyle::Top => MessageFrameStyle::Top,
282                SectionFrameStyle::Bottom => MessageFrameStyle::Bottom,
283                SectionFrameStyle::TopBottom => MessageFrameStyle::TopBottom,
284                SectionFrameStyle::Square => MessageFrameStyle::Square,
285                SectionFrameStyle::Round => MessageFrameStyle::Round,
286            },
287            ruled_policy: match settings.ruled_section_policy {
288                RuledSectionPolicy::PerSection => MessageRulePolicy::PerSection,
289                RuledSectionPolicy::Shared => MessageRulePolicy::Shared,
290            },
291            unicode: resolved.unicode,
292            width: resolved.width,
293        },
294    )
295}
296
297pub(crate) fn render_messages_without_config(
298    settings: &RenderSettings,
299    messages: &MessageBuffer,
300    verbosity: MessageLevel,
301) -> String {
302    let resolved = resolve_settings(settings, RenderProfile::Normal);
303    let styler = ThemeStyler::new(resolved.color, &resolved.theme, &resolved.style_overrides);
304    render_messages_with_styler_and_chrome(
305        &visible_message_buffer(messages),
306        MessageRenderOptions::full(verbosity),
307        &styler,
308        MessageChrome {
309            frame_style: MessageFrameStyle::TopBottom,
310            ruled_policy: MessageRulePolicy::Shared,
311            unicode: resolved.unicode,
312            width: resolved.width.or(Some(12)),
313        },
314    )
315}
316
317fn visible_message_buffer(messages: &MessageBuffer) -> MessageBuffer {
318    let mut out = MessageBuffer::default();
319    for entry in messages.entries() {
320        out.push(entry.level, visible_inline_text(&entry.text));
321    }
322    out
323}
324
325fn default_message_chrome_frame(layout: MessageLayout) -> SectionFrameStyle {
326    match layout {
327        MessageLayout::Minimal | MessageLayout::Plain | MessageLayout::Compact => {
328            SectionFrameStyle::None
329        }
330        MessageLayout::Grouped => SectionFrameStyle::TopBottom,
331    }
332}
333
334fn message_frame_style(frame: SectionFrameStyle) -> render::MessageFrameStyle {
335    match frame {
336        SectionFrameStyle::None => render::MessageFrameStyle::None,
337        SectionFrameStyle::Top => render::MessageFrameStyle::Top,
338        SectionFrameStyle::Bottom => render::MessageFrameStyle::Bottom,
339        SectionFrameStyle::TopBottom => render::MessageFrameStyle::TopBottom,
340        SectionFrameStyle::Square => render::MessageFrameStyle::Square,
341        SectionFrameStyle::Round => render::MessageFrameStyle::Round,
342    }
343}
344
345pub fn adjust_verbosity(base: MessageLevel, verbose: u8, quiet: u8) -> MessageLevel {
346    let rank = base.as_rank() + verbose as i8 - quiet as i8;
347    MessageLevel::from_rank(rank)
348}
349
350impl UiMessage {
351    pub(crate) fn new(level: MessageLevel, text: impl Into<String>) -> Self {
352        Self {
353            level,
354            text: text.into(),
355        }
356    }
357}