cfgd_core/output/renderer/
status.rs1use 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
8pub 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 pub fn render_status(&self, w: &dyn Writer, depth: usize, f: &StatusFields<'_>) {
23 if self.verbosity == Verbosity::Quiet && f.role != Role::Fail {
25 return;
26 }
27 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 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 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 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 if let Some(detail) = f.detail {
86 line.push_str(" — ");
87 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 assert!(
266 !raw.contains("\x1b[31m"),
267 "detail's red SGR must be stripped before push_str; got raw: {raw:?}"
268 );
269 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}