Skip to main content

standout_render/
warnings.rs

1//! Framework warning collection and deferred rendering.
2//!
3//! Some parts of standout-render (notably the embedded-resource hot-reload
4//! path in [`crate::embedded`]) can encounter non-fatal problems during
5//! application startup — e.g. a stylesheet fails to parse and the framework
6//! silently falls back to the compile-time embedded copy. Historically these
7//! were emitted via `eprintln!` *during* initialization, which meant they
8//! printed *before* the command's own output and as plain text, even when
9//! rendering into a rich terminal.
10//!
11//! This module routes those messages through a process-local collector so
12//! the CLI layer can render them *after* the command output, styled through
13//! the active theme, with a clear banner separating them from the rest of
14//! the terminal session.
15//!
16//! # Scope
17//!
18//! Only *framework warnings* (problems with standout's own setup / resource
19//! loading) should go through this module. User-facing diagnostics that are
20//! part of a handler's legitimate output — clipboard access failures, input
21//! validation feedback, handler-generated I/O errors — stay on stderr as
22//! before; interleaving them with other output is the correct behavior.
23//!
24//! # Usage
25//!
26//! Inside the framework, call [`push_warning`] instead of `eprintln!`:
27//!
28//! ```rust,ignore
29//! use standout_render::warnings::push_warning;
30//! push_warning(format!("Failed to parse stylesheets from '{}': {}", path, err));
31//! ```
32//!
33//! The CLI layer drains the collector at the end of `App::run` and renders
34//! the batch through the theme; see the `standout` crate for the flush
35//! logic.
36
37use std::cell::RefCell;
38use std::io::Write;
39
40use crate::output::OutputMode;
41use crate::theme::Theme;
42
43thread_local! {
44    /// Thread-local buffer of framework warnings collected during this run.
45    ///
46    /// A CLI process is effectively single-threaded for the duration of
47    /// `App::run` (handlers themselves may spawn threads, but framework
48    /// warnings come from the main-thread setup path), so a thread-local
49    /// is sufficient and avoids the overhead of a mutex.
50    static WARNINGS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
51}
52
53/// Appends a framework warning to the thread-local collector.
54///
55/// The warning is stored verbatim — callers should format a complete,
56/// self-contained message (no trailing newline). The CLI layer adds the
57/// tab indent and banner when flushing.
58pub fn push_warning(message: impl Into<String>) {
59    WARNINGS.with(|w| w.borrow_mut().push(message.into()));
60}
61
62/// Removes and returns all collected warnings for the current thread.
63///
64/// After this call the collector is empty. The CLI layer calls this once
65/// at the end of `App::run` to render the batch.
66pub fn drain_warnings() -> Vec<String> {
67    WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
68}
69
70/// Returns `true` if any warnings are currently buffered for this thread.
71///
72/// Intended for hot-path checks that want to skip the rendering work when
73/// there is nothing to emit.
74pub fn has_warnings() -> bool {
75    WARNINGS.with(|w| !w.borrow().is_empty())
76}
77
78/// Style name for the "Standout :: Warnings" banner, looked up in the theme.
79pub const WARNING_BANNER_STYLE: &str = "standout_warning_banner";
80
81/// Style name for each individual warning line, looked up in the theme.
82pub const WARNING_ITEM_STYLE: &str = "standout_warning_item";
83
84/// Literal banner text. Leading/trailing spaces give the background color
85/// room to breathe when the banner is styled with a bg fill.
86const BANNER_TEXT: &str = " Standout :: Warnings ";
87
88/// Drains the collector and emits the warnings to stderr.
89///
90/// Called by the CLI layer at the end of `App::run`, *after* the command
91/// output has been written to stdout, so the banner is the last thing the
92/// user sees. Does nothing if no warnings have been collected.
93///
94/// # Styling
95///
96/// Styling is applied when stderr is a TTY that supports color and
97/// `output_mode` does not explicitly forbid ANSI output (`Text` mode). The
98/// banner pulls its style from [`WARNING_BANNER_STYLE`] in `theme`; each
99/// warning line pulls from [`WARNING_ITEM_STYLE`]. Themes that don't define
100/// these styles fall back to unstyled text.
101pub fn flush_to_stderr(theme: &Theme, output_mode: OutputMode) {
102    let warnings = drain_warnings();
103    if warnings.is_empty() {
104        return;
105    }
106
107    let use_color = should_style_stderr(output_mode);
108    let styles = theme.resolve_styles(None);
109
110    // Write everything through a single stderr lock so the banner and its
111    // items cannot be interleaved with other output on a shared stream.
112    let stderr = std::io::stderr();
113    let mut out = stderr.lock();
114
115    let _ = writeln!(out);
116    let _ = writeln!(
117        out,
118        "{}",
119        style_for_stderr(&styles, WARNING_BANNER_STYLE, BANNER_TEXT, use_color)
120    );
121
122    for w in warnings {
123        let _ = writeln!(
124            out,
125            "\t{}",
126            style_for_stderr(&styles, WARNING_ITEM_STYLE, &w, use_color)
127        );
128    }
129}
130
131/// Applies `style_name` to `text`, forcing ANSI on/off based on `use_color`
132/// rather than the crate-wide `console::colors_enabled()` (which tracks
133/// stdout). This matters when stdout is piped but stderr is still a TTY:
134/// `Styles::apply` would see the global flag and strip codes we actually
135/// want to keep for stderr.
136///
137/// Falls back to unstyled text when the style is absent or `use_color` is
138/// false, rather than applying the "missing style" indicator — a warning
139/// with a stray `?` in front of it would be a worse UX than a plain one.
140fn style_for_stderr(
141    styles: &crate::style::Styles,
142    style_name: &str,
143    text: &str,
144    use_color: bool,
145) -> String {
146    if !use_color {
147        return text.to_string();
148    }
149    match styles.resolve(style_name) {
150        Some(style) => style
151            .clone()
152            .for_stderr()
153            .force_styling(true)
154            .apply_to(text)
155            .to_string(),
156        None => text.to_string(),
157    }
158}
159
160/// Decides whether the warnings block should use ANSI styling.
161///
162/// `OutputMode::Text` explicitly opts out of color. Structured modes
163/// (`Json`/`Yaml`/`Xml`/`Csv`) target stdout, not stderr, so they don't
164/// constrain our styling choices here — stderr TTY capability is what
165/// matters. `TermDebug` emits bracket tags instead of ANSI in the main
166/// output, but the warnings banner isn't subject to that contract, so we
167/// still honor the stderr TTY signal.
168fn should_style_stderr(output_mode: OutputMode) -> bool {
169    if matches!(output_mode, OutputMode::Text) {
170        return false;
171    }
172    console::Term::stderr().features().colors_supported()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use console::Style;
179
180    fn reset() {
181        let _ = drain_warnings();
182    }
183
184    #[test]
185    fn push_and_drain_roundtrip() {
186        reset();
187
188        assert!(!has_warnings());
189        push_warning("first");
190        push_warning(String::from("second"));
191        assert!(has_warnings());
192
193        let drained = drain_warnings();
194        assert_eq!(drained, vec!["first".to_string(), "second".to_string()]);
195        assert!(!has_warnings());
196
197        // Draining again yields nothing.
198        assert!(drain_warnings().is_empty());
199    }
200
201    #[test]
202    fn default_theme_registers_warning_styles() {
203        // Regression check: if Theme::default ever stops shipping these styles
204        // the flush helper silently emits plain text, so bake the presence of
205        // the style names into a test.
206        let theme = Theme::default();
207        let styles = theme.resolve_styles(None);
208        assert!(
209            styles.has(WARNING_BANNER_STYLE),
210            "Theme::default missing '{}'",
211            WARNING_BANNER_STYLE
212        );
213        assert!(
214            styles.has(WARNING_ITEM_STYLE),
215            "Theme::default missing '{}'",
216            WARNING_ITEM_STYLE
217        );
218    }
219
220    #[test]
221    fn style_for_stderr_plain_when_color_disabled() {
222        let mut styles = crate::style::Styles::new();
223        styles = styles.add("some_style", Style::new().red());
224        let out = style_for_stderr(&styles, "some_style", "hello", false);
225        assert_eq!(out, "hello");
226    }
227
228    #[test]
229    fn style_for_stderr_plain_when_style_missing() {
230        let styles = crate::style::Styles::new();
231        let out = style_for_stderr(&styles, "no_such_style", "hello", true);
232        // Fall back to plain text rather than emitting the missing-style marker.
233        assert_eq!(out, "hello");
234    }
235
236    #[test]
237    fn style_for_stderr_emits_ansi_when_enabled() {
238        let styles = crate::style::Styles::new().add("warn", Style::new().red().bold());
239        let out = style_for_stderr(&styles, "warn", "hello", true);
240        assert!(
241            out.contains("\x1b["),
242            "expected ANSI escape in styled output, got: {:?}",
243            out
244        );
245        assert!(out.contains("hello"));
246    }
247}