webui_dev_server/reporter.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Terminal reporter for dev-server rebuild loops.
5//!
6//! [`RebuildReporter`] manages the rolling-line UX where consecutive
7//! successful rebuilds repaint a single terminal line in place,
8//! while errors and banners commit it with a newline. It also
9//! emits a wall-clock timestamp so users can see when each
10//! rebuild fired without needing to glance at a clock.
11//!
12//! Sample output across three rebuilds and one error:
13//!
14//! ```text
15//! ⚡ WebUI dev server
16//! ➜ http://localhost:3333/
17//!
18//! ↻ rebuilt app-shell.css in 0.3s 16:42:51 ← repainted in place
19//! ↻ rebuilt index.html in 0.4s 16:42:58 ← (replaces previous line)
20//!
21//! ✘ build error: parse failed ← framed by blank lines
22//!
23//! ↻ rebuilt styles.css (+2 more) in 0.2s 16:43:10
24//! ```
25
26use std::collections::HashSet;
27use std::io::{IsTerminal, Write};
28use std::path::PathBuf;
29use std::time::Duration;
30
31use console::style;
32
33/// ANSI escape: carriage return + clear-to-end-of-line. Used to repaint
34/// the previous "rebuilt" line in place when consecutive rebuilds fire.
35const REWIND_LINE: &str = "\r\x1b[2K";
36
37/// Stateful terminal reporter for rebuild loops.
38///
39/// Tracks whether the previous output was a rolling rebuild line so the
40/// next line can either repaint it (consecutive success) or commit it
41/// with a newline (error, banner). Cheap to construct; not `Clone` —
42/// instances are typically owned by a single rebuild worker.
43///
44/// When stderr is not a TTY (piped to a logger, captured by tools like
45/// `concurrently`, or running in CI), the rolling line is replaced with
46/// plain newline-terminated lines so wrappers can flush each rebuild
47/// promptly.
48pub struct RebuildReporter {
49 last_was_rebuild: bool,
50 /// True when the previous committed output was a framed diagnostic block (a
51 /// build error or warning). Lets consecutive blocks share a single
52 /// separating blank line (each block prints a trailing blank; only the first
53 /// in a run prints a leading one) instead of stacking a double blank.
54 last_was_block: bool,
55 /// True when stderr is an interactive terminal that supports the
56 /// `\r\x1b[2K` repaint trick. Captured once at construction —
57 /// stream redirection rarely changes mid-process and re-querying
58 /// per write would be wasteful.
59 interactive: bool,
60}
61
62impl Default for RebuildReporter {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl RebuildReporter {
69 /// Create a fresh reporter. Detects whether stderr is a TTY once.
70 #[must_use]
71 pub fn new() -> Self {
72 Self {
73 last_was_rebuild: false,
74 last_was_block: false,
75 interactive: std::io::stderr().is_terminal(),
76 }
77 }
78
79 /// Print a successful rebuild line. In an interactive terminal,
80 /// consecutive successes repaint a single line in place; otherwise
81 /// each rebuild prints its own newline-terminated line so log
82 /// wrappers (`concurrently`, CI capture, file redirects) flush it.
83 ///
84 /// `triggers` are the changed paths that caused this rebuild; the line names
85 /// the first file (and `(+N more)` when several changed) so the developer
86 /// can see what fired. An empty slice prints no trigger.
87 ///
88 /// Always writes to **stderr** so it lands on the same stream as
89 /// the rest of the dev server's diagnostic output (banners, field
90 /// tables, errors).
91 pub fn success(&mut self, elapsed: Duration, triggers: &[PathBuf]) {
92 let elapsed_str = format_elapsed(elapsed);
93 let trigger = format_trigger(triggers);
94 let rebuilt = match &trigger {
95 Some(name) => format!("rebuilt {name}"),
96 None => "rebuilt".to_string(),
97 };
98 if self.interactive {
99 let prefix = if self.last_was_rebuild {
100 REWIND_LINE
101 } else {
102 ""
103 };
104 eprint!(
105 "{} {} {} {} {}",
106 prefix,
107 style("↻").cyan().bold(),
108 style(rebuilt).bold(),
109 style(format!("in {elapsed_str}")).dim(),
110 style(local_hms()).dim(),
111 );
112 let _ = std::io::stderr().flush();
113 } else {
114 eprintln!(
115 " {} {} {} {}",
116 style("↻").cyan().bold(),
117 style(rebuilt).bold(),
118 style(format!("in {elapsed_str}")).dim(),
119 style(local_hms()).dim(),
120 );
121 }
122 self.last_was_rebuild = true;
123 self.last_was_block = false;
124 }
125
126 /// Print a rebuild error, committing the previous rolling line first
127 /// so it isn't overwritten.
128 ///
129 /// Each error block is framed with surrounding blank lines so that
130 /// consecutive rebuild errors (e.g. repeated saves of a still-broken file)
131 /// read as separate blocks instead of one squished wall. The leading blank
132 /// is printed only for the first error in a run; every error prints a
133 /// trailing blank, so two adjacent errors are separated by exactly one
134 /// blank line.
135 pub fn error(&mut self, msg: &str) {
136 self.commit_pending();
137 if !self.last_was_block {
138 eprintln!();
139 }
140 // Color only the leading marker here. The body (`msg`) arrives
141 // pre-rendered from the caller — either plain, or per-line colorized
142 // with self-contained SGR spans. We must never wrap it in one span
143 // that straddles newlines: that bleeds when each line is later
144 // re-emitted with a process prefix (e.g. `[server]`).
145 eprintln!(
146 " {} {} {msg}",
147 style("✘").red().bold(),
148 style("build error:").red().bold(),
149 );
150 eprintln!();
151 self.last_was_block = true;
152 }
153
154 /// Print non-fatal rebuild advisories, each framed with surrounding blank
155 /// lines so they read as distinct blocks (matching [`error`]). The leading
156 /// blank is printed only when the previous output was not itself a framed
157 /// block; every advisory prints a trailing blank.
158 ///
159 /// No-op when `messages` is empty, so clean rebuilds keep their in-place
160 /// rolling line. Each `message` is the caller's pre-rendered diagnostic body
161 /// (the CLI colorizes per line); the reporter adds the `⚠ build warning:`
162 /// marker.
163 ///
164 /// [`error`]: Self::error
165 pub fn warnings(&mut self, messages: &[String]) {
166 if messages.is_empty() {
167 return;
168 }
169 self.commit_pending();
170 for message in messages {
171 if !self.last_was_block {
172 eprintln!();
173 }
174 eprintln!(
175 " {} {} {message}",
176 style("⚠").yellow().bold(),
177 style("build warning:").yellow().bold(),
178 );
179 eprintln!();
180 self.last_was_block = true;
181 }
182 }
183
184 /// Commit any pending rolling rebuild line with a trailing newline,
185 /// so subsequent unrelated output (banner, info message) doesn't
186 /// land on the same line. No-op when not in interactive mode
187 /// (those lines are already newline-terminated).
188 pub fn commit_pending(&mut self) {
189 if self.last_was_rebuild && self.interactive {
190 eprintln!();
191 }
192 self.last_was_rebuild = false;
193 }
194}
195
196/// Format a rebuild duration with the smallest sensible unit:
197/// sub-second elapses render as `123ms` (no fractional digit lost),
198/// while longer elapses fall back to `1.2s` so the line stays compact.
199fn format_elapsed(elapsed: Duration) -> String {
200 let ms = elapsed.as_millis();
201 if ms < 1_000 {
202 format!("{ms}ms")
203 } else {
204 format!("{:.1}s", elapsed.as_secs_f32())
205 }
206}
207
208/// Format the rebuild trigger from the changed paths: the first file's name,
209/// with `(+N more)` when several distinct files changed. Returns `None` for an
210/// empty trigger set (e.g. a forced rebuild). File names are deduplicated
211/// preserving first-seen order so repeated events for one file read as one.
212fn format_trigger(triggers: &[PathBuf]) -> Option<String> {
213 let mut seen = HashSet::new();
214 let mut names: Vec<&str> = Vec::new();
215 for path in triggers {
216 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
217 if seen.insert(name) {
218 names.push(name);
219 }
220 }
221 }
222 match names.as_slice() {
223 [] => None,
224 [only] => Some((*only).to_string()),
225 [first, rest @ ..] => Some(format!("{first} (+{} more)", rest.len())),
226 }
227}
228
229/// Format wall-clock local time as `HH:MM:SS`. Falls back to UTC if the
230/// platform refuses to expose the local offset (e.g. some sandboxed
231/// environments where `time` cannot read the system timezone).
232#[must_use]
233pub fn local_hms() -> String {
234 if let Ok(now) = time::OffsetDateTime::now_local() {
235 return format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second());
236 }
237 let now = time::OffsetDateTime::now_utc();
238 format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second())
239}
240
241#[cfg(test)]
242#[allow(clippy::disallowed_methods)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn local_hms_returns_eight_chars() {
248 let s = local_hms();
249 assert_eq!(s.len(), 8);
250 assert_eq!(s.as_bytes()[2], b':');
251 assert_eq!(s.as_bytes()[5], b':');
252 }
253
254 #[test]
255 fn reporter_tracks_pending_state() {
256 let mut r = RebuildReporter::new();
257 assert!(!r.last_was_rebuild);
258 r.success(Duration::from_millis(123), &[]);
259 assert!(r.last_was_rebuild);
260 r.commit_pending();
261 assert!(!r.last_was_rebuild);
262 }
263
264 #[test]
265 fn error_resets_pending() {
266 let mut r = RebuildReporter::new();
267 r.success(Duration::from_millis(50), &[]);
268 r.error("oops");
269 assert!(!r.last_was_rebuild);
270 }
271
272 #[test]
273 fn framed_blocks_track_state_for_blank_line_framing() {
274 // Errors and warnings are both framed blocks: the first in a run is
275 // flagged so consecutive blocks share a single separating blank line; a
276 // success (rolling line) clears the flag so the next block reprints its
277 // leading blank.
278 let mut r = RebuildReporter::new();
279 r.error("boom");
280 assert!(r.last_was_block);
281 r.error("still boom");
282 assert!(r.last_was_block);
283
284 r.success(Duration::from_millis(5), &[]);
285 assert!(!r.last_was_block);
286
287 // Warnings are framed blocks too, so they set (and keep) the flag.
288 r.warnings(&["likely typo".to_string()]);
289 assert!(r.last_was_block);
290 r.warnings(&["another".to_string()]);
291 assert!(r.last_was_block);
292
293 r.success(Duration::from_millis(2), &[]);
294 assert!(!r.last_was_block);
295 }
296
297 #[test]
298 fn empty_warnings_keep_rolling_line() {
299 // No advisories → the rolling rebuild line is preserved for in-place
300 // repaint on the next success.
301 let mut r = RebuildReporter::new();
302 r.success(Duration::from_millis(50), &[]);
303 r.warnings(&[]);
304 assert!(r.last_was_rebuild);
305 }
306
307 #[test]
308 fn warnings_commit_pending_line() {
309 // A non-empty advisory commits the rolling line so it isn't clobbered.
310 let mut r = RebuildReporter::new();
311 r.success(Duration::from_millis(50), &[]);
312 r.warnings(&["CSS token --colr-brand is a likely typo".to_string()]);
313 assert!(!r.last_was_rebuild);
314 }
315
316 #[test]
317 fn format_trigger_renders_file_names() {
318 assert_eq!(format_trigger(&[]), None);
319 assert_eq!(
320 format_trigger(&[PathBuf::from("/app/app-shell/app-shell.css")]),
321 Some("app-shell.css".to_string())
322 );
323 // Repeated events for the same file collapse to one name.
324 assert_eq!(
325 format_trigger(&[PathBuf::from("/app/a.css"), PathBuf::from("/app/sub/a.css"),]),
326 Some("a.css".to_string())
327 );
328 // Several distinct files → first + count of the rest.
329 assert_eq!(
330 format_trigger(&[
331 PathBuf::from("/app/a.css"),
332 PathBuf::from("/app/b.html"),
333 PathBuf::from("/app/c.ts"),
334 ]),
335 Some("a.css (+2 more)".to_string())
336 );
337 }
338
339 #[test]
340 fn format_elapsed_uses_ms_below_one_second() {
341 assert_eq!(format_elapsed(Duration::from_millis(0)), "0ms");
342 assert_eq!(format_elapsed(Duration::from_millis(7)), "7ms");
343 assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
344 }
345
346 #[test]
347 fn format_elapsed_uses_seconds_at_or_above_one_second() {
348 assert_eq!(format_elapsed(Duration::from_millis(1_000)), "1.0s");
349 assert_eq!(format_elapsed(Duration::from_millis(1_250)), "1.2s");
350 assert_eq!(format_elapsed(Duration::from_secs(12)), "12.0s");
351 }
352}