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 in 0.3s 16:42:51   ← repainted in place
19//!   ↻ rebuilt in 0.4s 16:42:58   ← (replaces previous line)
20//!   ✘ build error: parse failed  ← commits previous line, prints below
21//!   ↻ rebuilt in 0.2s 16:43:10
22//! ```
23
24use std::io::{IsTerminal, Write};
25use std::time::Duration;
26
27use console::style;
28
29/// ANSI escape: carriage return + clear-to-end-of-line. Used to repaint
30/// the previous "rebuilt" line in place when consecutive rebuilds fire.
31const REWIND_LINE: &str = "\r\x1b[2K";
32
33/// Stateful terminal reporter for rebuild loops.
34///
35/// Tracks whether the previous output was a rolling rebuild line so the
36/// next line can either repaint it (consecutive success) or commit it
37/// with a newline (error, banner). Cheap to construct; not `Clone` —
38/// instances are typically owned by a single rebuild worker.
39///
40/// When stderr is not a TTY (piped to a logger, captured by tools like
41/// `concurrently`, or running in CI), the rolling line is replaced with
42/// plain newline-terminated lines so wrappers can flush each rebuild
43/// promptly.
44pub struct RebuildReporter {
45    last_was_rebuild: bool,
46    /// True when stderr is an interactive terminal that supports the
47    /// `\r\x1b[2K` repaint trick. Captured once at construction —
48    /// stream redirection rarely changes mid-process and re-querying
49    /// per write would be wasteful.
50    interactive: bool,
51}
52
53impl Default for RebuildReporter {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl RebuildReporter {
60    /// Create a fresh reporter. Detects whether stderr is a TTY once.
61    #[must_use]
62    pub fn new() -> Self {
63        Self {
64            last_was_rebuild: false,
65            interactive: std::io::stderr().is_terminal(),
66        }
67    }
68
69    /// Print a successful rebuild line. In an interactive terminal,
70    /// consecutive successes repaint a single line in place; otherwise
71    /// each rebuild prints its own newline-terminated line so log
72    /// wrappers (`concurrently`, CI capture, file redirects) flush it.
73    ///
74    /// Always writes to **stderr** so it lands on the same stream as
75    /// the rest of the dev server's diagnostic output (banners, field
76    /// tables, errors).
77    pub fn success(&mut self, elapsed: Duration) {
78        let elapsed_str = format_elapsed(elapsed);
79        if self.interactive {
80            let prefix = if self.last_was_rebuild {
81                REWIND_LINE
82            } else {
83                ""
84            };
85            eprint!(
86                "{}  {} {} {} {}",
87                prefix,
88                style("↻").cyan().bold(),
89                style("rebuilt").bold(),
90                style(format!("in {elapsed_str}")).dim(),
91                style(local_hms()).dim(),
92            );
93            let _ = std::io::stderr().flush();
94        } else {
95            eprintln!(
96                "  {} {} {} {}",
97                style("↻").cyan().bold(),
98                style("rebuilt").bold(),
99                style(format!("in {elapsed_str}")).dim(),
100                style(local_hms()).dim(),
101            );
102        }
103        self.last_was_rebuild = true;
104    }
105
106    /// Print a rebuild error, committing the previous rolling line first
107    /// so it isn't overwritten.
108    pub fn error(&mut self, msg: &str) {
109        self.commit_pending();
110        // Color only the leading marker here. The body (`msg`) arrives
111        // pre-rendered from the caller — either plain, or per-line colorized
112        // with self-contained SGR spans. We must never wrap it in one span
113        // that straddles newlines: that bleeds when each line is later
114        // re-emitted with a process prefix (e.g. `[server]`).
115        eprintln!(
116            "  {} {} {msg}",
117            style("✘").red().bold(),
118            style("build error:").red().bold(),
119        );
120    }
121
122    /// Commit any pending rolling rebuild line with a trailing newline,
123    /// so subsequent unrelated output (banner, info message) doesn't
124    /// land on the same line. No-op when not in interactive mode
125    /// (those lines are already newline-terminated).
126    pub fn commit_pending(&mut self) {
127        if self.last_was_rebuild && self.interactive {
128            eprintln!();
129        }
130        self.last_was_rebuild = false;
131    }
132}
133
134/// Format a rebuild duration with the smallest sensible unit:
135/// sub-second elapses render as `123ms` (no fractional digit lost),
136/// while longer elapses fall back to `1.2s` so the line stays compact.
137fn format_elapsed(elapsed: Duration) -> String {
138    let ms = elapsed.as_millis();
139    if ms < 1_000 {
140        format!("{ms}ms")
141    } else {
142        format!("{:.1}s", elapsed.as_secs_f32())
143    }
144}
145
146/// Format wall-clock local time as `HH:MM:SS`. Falls back to UTC if the
147/// platform refuses to expose the local offset (e.g. some sandboxed
148/// environments where `time` cannot read the system timezone).
149#[must_use]
150pub fn local_hms() -> String {
151    if let Ok(now) = time::OffsetDateTime::now_local() {
152        return format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second());
153    }
154    let now = time::OffsetDateTime::now_utc();
155    format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second())
156}
157
158#[cfg(test)]
159#[allow(clippy::disallowed_methods)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn local_hms_returns_eight_chars() {
165        let s = local_hms();
166        assert_eq!(s.len(), 8);
167        assert_eq!(s.as_bytes()[2], b':');
168        assert_eq!(s.as_bytes()[5], b':');
169    }
170
171    #[test]
172    fn reporter_tracks_pending_state() {
173        let mut r = RebuildReporter::new();
174        assert!(!r.last_was_rebuild);
175        r.success(Duration::from_millis(123));
176        assert!(r.last_was_rebuild);
177        r.commit_pending();
178        assert!(!r.last_was_rebuild);
179    }
180
181    #[test]
182    fn error_resets_pending() {
183        let mut r = RebuildReporter::new();
184        r.success(Duration::from_millis(50));
185        r.error("oops");
186        assert!(!r.last_was_rebuild);
187    }
188
189    #[test]
190    fn format_elapsed_uses_ms_below_one_second() {
191        assert_eq!(format_elapsed(Duration::from_millis(0)), "0ms");
192        assert_eq!(format_elapsed(Duration::from_millis(7)), "7ms");
193        assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
194    }
195
196    #[test]
197    fn format_elapsed_uses_seconds_at_or_above_one_second() {
198        assert_eq!(format_elapsed(Duration::from_millis(1_000)), "1.0s");
199        assert_eq!(format_elapsed(Duration::from_millis(1_250)), "1.2s");
200        assert_eq!(format_elapsed(Duration::from_secs(12)), "12.0s");
201    }
202}