Skip to main content

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}