Skip to main content

cfgd_core/output/renderer/
status.rs

1use std::path::Path;
2use std::time::Duration;
3
4use super::{Renderer, Writer, role_glyph};
5use crate::PathDisplayExt;
6use crate::output::{Role, Verbosity, strip_ansi};
7
8/// Inputs to a single Status line. Builders convert to this for rendering.
9pub struct StatusFields<'a> {
10    pub role: Role,
11    pub subject: &'a str,
12    pub detail: Option<&'a str>,
13    pub duration: Option<Duration>,
14    pub target: Option<&'a Path>,
15}
16
17impl Renderer {
18    /// Top-level status dispatcher. Routes to the topmost open section's
19    /// pending-statuses buffer when one exists (so subjects can be
20    /// right-padded to a common column at section close); otherwise writes
21    /// immediately.
22    pub fn render_status(&self, w: &dyn Writer, depth: usize, f: &StatusFields<'_>) {
23        // Status(Fail) is shown even at Quiet.
24        if self.verbosity == Verbosity::Quiet && f.role != Role::Fail {
25            return;
26        }
27        // Buffer when a section is open AND this status's depth is inside
28        // (not equal to) the section's header_depth. The depth==header_depth
29        // case happens for re-routed top-level emits via `enforce_top_level_emit`;
30        // those should render immediately so the warning shape stays inline.
31        let buffered = {
32            let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
33            let mut did_buffer = false;
34            if let Some(top) = s.section_stack.last_mut()
35                && depth > top.header_depth
36            {
37                // Inside the section's child region — buffer.
38                top.pending_statuses.push(super::section::BufferedStatus {
39                    role: f.role,
40                    subject: f.subject.to_string(),
41                    detail: f.detail.map(|d| d.to_string()),
42                    duration: f.duration,
43                    target: f.target.map(|p| p.to_path_buf()),
44                    depth,
45                });
46                did_buffer = true;
47            }
48            did_buffer
49        };
50        if buffered {
51            // Header emission must still happen so the section's header
52            // appears before any of its children. This is idempotent — only
53            // the first call writes anything.
54            self.flush_pending_section_headers(w);
55            return;
56        }
57        self.render_status_immediate(w, depth, f);
58        self.mark_top_level_blank_if_at_root();
59    }
60
61    /// Actually emit a Status line, without buffering. Used by the immediate
62    /// path AND by `flush_pending_statuses` when a section closes.
63    pub(crate) fn render_status_immediate(
64        &self,
65        w: &dyn Writer,
66        depth: usize,
67        f: &StatusFields<'_>,
68    ) {
69        if self.verbosity == Verbosity::Quiet && f.role != Role::Fail {
70            return;
71        }
72        self.flush_pending_section_headers(w);
73
74        let (icon_opt, style) = role_glyph(&self.theme, f.role);
75        let mut line = String::new();
76        if let Some(icon) = icon_opt {
77            line.push_str(&style.apply_to(icon).to_string());
78            line.push(' ');
79        }
80        line.push_str(&style.apply_to(f.subject).to_string());
81
82        // Field order: subject — detail (target). Detail comes first (with
83        // em-dash glue), then target in parens. Duration trails last as its
84        // own (Ns) parens block.
85        if let Some(detail) = f.detail {
86            line.push_str(" — ");
87            // Sanitize at the renderer boundary: detail may carry external
88            // tool stderr or `format!("{e}")` content with embedded ANSI
89            // escapes. A stray `\x1b[0m` would prematurely terminate the
90            // role styling above; foreign color escapes would paint
91            // subsequent terminal output until the next reset.
92            line.push_str(&strip_ansi(detail));
93        }
94        if let Some(target) = f.target {
95            let dim = self.theme.muted.apply_to(format!(" ({})", target.posix()));
96            line.push_str(&dim.to_string());
97        }
98        if let Some(d) = f.duration {
99            let secs = d.as_secs_f64();
100            let dim = self.theme.muted.apply_to(format!(" ({:.1}s)", secs));
101            line.push_str(&dim.to_string());
102        }
103        self.write_line(w, depth, &line);
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use std::sync::{Arc, Mutex};
110
111    use super::super::StringSink;
112    use super::*;
113    use crate::output::Theme;
114    use crate::output::strip_ansi;
115
116    fn capture() -> (Renderer, StringSink, Arc<Mutex<String>>) {
117        let buf = Arc::new(Mutex::new(String::new()));
118        let sink = StringSink(buf.clone());
119        let r = Renderer::new(Theme::default(), Verbosity::Normal);
120        (r, sink, buf)
121    }
122
123    #[test]
124    fn ok_status_renders_check_glyph() {
125        let (r, sink, buf) = capture();
126        r.render_status(
127            &sink,
128            0,
129            &StatusFields {
130                role: Role::Ok,
131                subject: "done",
132                detail: None,
133                duration: None,
134                target: None,
135            },
136        );
137        let out = strip_ansi(&buf.lock().unwrap());
138        assert!(out.contains("✓ done"), "got: {out:?}");
139    }
140
141    #[test]
142    fn info_role_has_no_icon() {
143        let (r, sink, buf) = capture();
144        r.render_status(
145            &sink,
146            0,
147            &StatusFields {
148                role: Role::Info,
149                subject: "note",
150                detail: None,
151                duration: None,
152                target: None,
153            },
154        );
155        let out = strip_ansi(&buf.lock().unwrap());
156        assert_eq!(out.trim_end(), "note");
157    }
158
159    #[test]
160    fn detail_appended_with_em_dash() {
161        let (r, sink, buf) = capture();
162        r.render_status(
163            &sink,
164            0,
165            &StatusFields {
166                role: Role::Fail,
167                subject: "/tmp/foo",
168                detail: Some("permission denied"),
169                duration: None,
170                target: None,
171            },
172        );
173        let out = strip_ansi(&buf.lock().unwrap());
174        assert!(
175            out.contains("✗ /tmp/foo — permission denied"),
176            "got: {out:?}"
177        );
178    }
179
180    #[test]
181    fn duration_trailed_in_parens() {
182        let (r, sink, buf) = capture();
183        r.render_status(
184            &sink,
185            0,
186            &StatusFields {
187                role: Role::Ok,
188                subject: "done",
189                detail: None,
190                duration: Some(std::time::Duration::from_millis(1234)),
191                target: None,
192            },
193        );
194        let out = strip_ansi(&buf.lock().unwrap());
195        assert!(out.contains("(1.2s)"), "got: {out:?}");
196    }
197
198    #[test]
199    fn fail_shown_even_at_quiet() {
200        let buf = Arc::new(Mutex::new(String::new()));
201        let sink = StringSink(buf.clone());
202        let r = Renderer::new(Theme::default(), Verbosity::Quiet);
203        r.render_status(
204            &sink,
205            0,
206            &StatusFields {
207                role: Role::Fail,
208                subject: "boom",
209                detail: None,
210                duration: None,
211                target: None,
212            },
213        );
214        let out = strip_ansi(&buf.lock().unwrap());
215        assert!(
216            out.contains("boom"),
217            "Fail must render at Quiet; got: {out:?}"
218        );
219    }
220
221    #[test]
222    fn ok_suppressed_at_quiet() {
223        let buf = Arc::new(Mutex::new(String::new()));
224        let sink = StringSink(buf.clone());
225        let r = Renderer::new(Theme::default(), Verbosity::Quiet);
226        r.render_status(
227            &sink,
228            0,
229            &StatusFields {
230                role: Role::Ok,
231                subject: "done",
232                detail: None,
233                duration: None,
234                target: None,
235            },
236        );
237        assert!(buf.lock().unwrap().is_empty());
238    }
239
240    #[test]
241    fn detail_strips_ansi_to_prevent_terminal_paint() {
242        let (r, sink, buf) = capture();
243        let detail = "upstream: \x1b[31mred\x1b[0m text \x1b[1mbold\x1b[0m";
244        r.render_status(
245            &sink,
246            0,
247            &StatusFields {
248                role: Role::Fail,
249                subject: "sync failed",
250                detail: Some(detail),
251                duration: None,
252                target: None,
253            },
254        );
255        let raw = buf.lock().unwrap().clone();
256        let visible = strip_ansi(&raw);
257        assert!(
258            visible.contains("sync failed — upstream: red text bold"),
259            "visible composition mismatch; got: {visible:?}"
260        );
261        // The renderer's own SGR styles the Fail glyph + subject (bold red),
262        // so a blanket `!raw.contains("\\x1b[")` is too strict. Pick a SGR
263        // code the renderer would never emit for Fail (foreground red `31`)
264        // to prove the detail's escapes were sanitized away.
265        assert!(
266            !raw.contains("\x1b[31m"),
267            "detail's red SGR must be stripped before push_str; got raw: {raw:?}"
268        );
269        // And the stray `\x1b[0m` mid-detail must not survive — it would
270        // otherwise close the renderer's subject styling prematurely.
271        let detail_segment = raw.rsplit(" — ").next().unwrap_or("");
272        assert!(
273            !detail_segment.contains('\u{1b}'),
274            "detail segment must contain no ANSI escapes; got: {detail_segment:?}"
275        );
276    }
277}