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