Skip to main content

rskit_cli/render/
status.rs

1//! One-off status feedback lines for guided, multi-step CLI flows.
2//!
3//! Where [`crate::progress`] animates *ongoing* work and [`crate::prompt`] reads
4//! *input*, this renders the short, one-shot status lines a flow emits between
5//! steps: `✓ Detected Rust`, a `[1/4]` step counter, a section heading, a warn
6//! or error notice. It composes the two theme layers — a [`Palette`] for color
7//! and a [`Glyphs`] set for the leading symbol — so every line honours
8//! `NO_COLOR`, TTY detection, and UTF-8 capability exactly like the rest of the
9//! CLI surface.
10//!
11//! The writer is injected, so callers bind it to a real stream with
12//! [`StatusReporter::from_env`] (stderr, matching the "diagnostics to stderr"
13//! convention) while tests assert on an in-memory buffer via
14//! [`StatusReporter::new`].
15
16use std::io::{self, Write};
17
18use rskit_errors::{AppError, AppResult};
19
20use crate::theme::{ColorChoice, Glyphs, Palette};
21
22/// A status-line reporter over an injected writer, palette, and glyph set.
23pub struct StatusReporter<W> {
24    writer: W,
25    palette: Palette,
26    glyphs: Glyphs,
27}
28
29impl StatusReporter<io::Stderr> {
30    /// Build a reporter bound to process stderr.
31    ///
32    /// The [`Palette`] resolves from `color` against stderr and the [`Glyphs`]
33    /// from the process locale, so color and symbols both honour redirection,
34    /// `NO_COLOR`, and terminal encoding.
35    #[must_use]
36    pub fn from_env(color: ColorChoice) -> Self {
37        let stderr = io::stderr();
38        let palette = Palette::for_stream(color, &stderr);
39        Self {
40            writer: stderr,
41            palette,
42            glyphs: Glyphs::from_env(),
43        }
44    }
45}
46
47impl<W: Write> StatusReporter<W> {
48    /// Build a reporter from explicit parts.
49    #[must_use]
50    pub const fn new(writer: W, palette: Palette, glyphs: Glyphs) -> Self {
51        Self {
52            writer,
53            palette,
54            glyphs,
55        }
56    }
57
58    /// Emit a success line: a green check glyph followed by `message`.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error when the underlying writer fails.
63    pub fn success(&mut self, message: &str) -> AppResult<()> {
64        let prefix = self.palette.success(self.glyphs.success()).into_owned();
65        self.write_line(&prefix, message)
66    }
67
68    /// Emit an error line: a red cross glyph followed by `message`.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error when the underlying writer fails.
73    pub fn error(&mut self, message: &str) -> AppResult<()> {
74        let prefix = self.palette.error(self.glyphs.error()).into_owned();
75        self.write_line(&prefix, message)
76    }
77
78    /// Emit a warning line: a yellow warning glyph followed by `message`.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error when the underlying writer fails.
83    pub fn warn(&mut self, message: &str) -> AppResult<()> {
84        let prefix = self.palette.warn(self.glyphs.warning()).into_owned();
85        self.write_line(&prefix, message)
86    }
87
88    /// Emit an informational line: a cyan info glyph followed by `message`.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error when the underlying writer fails.
93    pub fn info(&mut self, message: &str) -> AppResult<()> {
94        let prefix = self.palette.info(self.glyphs.info()).into_owned();
95        self.write_line(&prefix, message)
96    }
97
98    /// Emit an indented bullet line: a dimmed bullet glyph followed by `message`.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when the underlying writer fails.
103    pub fn bullet(&mut self, message: &str) -> AppResult<()> {
104        let prefix = format!("  {}", self.palette.dim(self.glyphs.bullet()));
105        self.write_line(&prefix, message)
106    }
107
108    /// Emit a step line prefixed with a dimmed `[current/total]` counter.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error when the underlying writer fails.
113    pub fn step(&mut self, current: usize, total: usize, message: &str) -> AppResult<()> {
114        let counter = self
115            .palette
116            .dim(&format!("[{current}/{total}]"))
117            .into_owned();
118        self.write_line(&counter, message)
119    }
120
121    /// Emit a bold section heading preceded by a blank line.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error when the underlying writer fails.
126    pub fn heading(&mut self, title: &str) -> AppResult<()> {
127        let styled = self.palette.bold(title).into_owned();
128        writeln!(self.writer, "\n{styled}").map_err(AppError::internal)
129    }
130
131    fn write_line(&mut self, prefix: &str, message: &str) -> AppResult<()> {
132        writeln!(self.writer, "{prefix} {message}").map_err(AppError::internal)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::StatusReporter;
139    use crate::theme::{Glyphs, Palette};
140
141    fn reporter(buffer: &mut Vec<u8>, color: bool, unicode: bool) -> StatusReporter<&mut Vec<u8>> {
142        StatusReporter::new(buffer, Palette::new(color), Glyphs::new(unicode))
143    }
144
145    fn rendered(buffer: Vec<u8>) -> String {
146        String::from_utf8(buffer).expect("utf8")
147    }
148
149    #[test]
150    fn success_line_carries_glyph_and_message() {
151        let mut buffer = Vec::new();
152        reporter(&mut buffer, false, true)
153            .success("Detected Rust")
154            .expect("write");
155        let out = rendered(buffer);
156        assert!(out.contains('✓'));
157        assert!(out.contains("Detected Rust"));
158    }
159
160    #[test]
161    fn step_line_renders_counter() {
162        let mut buffer = Vec::new();
163        reporter(&mut buffer, false, true)
164            .step(1, 4, "Selecting ecosystems")
165            .expect("write");
166        let out = rendered(buffer);
167        assert!(out.contains("[1/4]"));
168        assert!(out.contains("Selecting ecosystems"));
169    }
170
171    #[test]
172    fn heading_is_preceded_by_blank_line() {
173        let mut buffer = Vec::new();
174        reporter(&mut buffer, false, true)
175            .heading("Configuration")
176            .expect("write");
177        let out = rendered(buffer);
178        assert!(out.starts_with('\n'));
179        assert!(out.contains("Configuration"));
180    }
181
182    #[test]
183    fn ascii_fallback_avoids_unicode_glyphs() {
184        let mut buffer = Vec::new();
185        reporter(&mut buffer, false, false)
186            .warn("no toolchain found")
187            .expect("write");
188        let out = rendered(buffer);
189        assert!(out.contains('!'));
190        assert!(!out.contains('⚠'));
191    }
192
193    #[test]
194    fn disabled_palette_is_byte_clean() {
195        let mut buffer = Vec::new();
196        reporter(&mut buffer, false, true)
197            .info("nothing to do")
198            .expect("write");
199        let out = rendered(buffer);
200        assert!(!out.contains('\u{1b}'), "no color must be byte-clean");
201    }
202
203    #[test]
204    fn enabled_palette_emits_ansi_escapes() {
205        let mut buffer = Vec::new();
206        reporter(&mut buffer, true, true)
207            .error("build failed")
208            .expect("write");
209        let out = rendered(buffer);
210        assert!(out.contains('\u{1b}'), "color must emit SGR escapes");
211    }
212
213    #[test]
214    fn bullet_line_is_indented_with_its_glyph() {
215        let mut buffer = Vec::new();
216        reporter(&mut buffer, false, true)
217            .bullet("cached crate")
218            .expect("write");
219        let out = rendered(buffer);
220        assert!(out.starts_with("  "));
221        assert!(out.contains('•'));
222        assert!(out.contains("cached crate"));
223    }
224
225    #[test]
226    fn from_env_binds_to_stderr_without_writing() {
227        // Constructing over the process stderr must succeed regardless of TTY or
228        // NO_COLOR state; it resolves the palette and glyphs but emits nothing.
229        let _reporter = StatusReporter::from_env(crate::theme::ColorChoice::Never);
230    }
231}