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}