use std::collections::HashSet;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::time::Duration;
use console::style;
const REWIND_LINE: &str = "\r\x1b[2K";
pub struct RebuildReporter {
last_was_rebuild: bool,
last_was_block: bool,
interactive: bool,
}
impl Default for RebuildReporter {
fn default() -> Self {
Self::new()
}
}
impl RebuildReporter {
#[must_use]
pub fn new() -> Self {
Self {
last_was_rebuild: false,
last_was_block: false,
interactive: std::io::stderr().is_terminal(),
}
}
pub fn success(&mut self, elapsed: Duration, triggers: &[PathBuf]) {
let elapsed_str = format_elapsed(elapsed);
let trigger = format_trigger(triggers);
let rebuilt = match &trigger {
Some(name) => format!("rebuilt {name}"),
None => "rebuilt".to_string(),
};
if self.interactive {
let prefix = if self.last_was_rebuild {
REWIND_LINE
} else {
""
};
eprint!(
"{} {} {} {} {}",
prefix,
style("↻").cyan().bold(),
style(rebuilt).bold(),
style(format!("in {elapsed_str}")).dim(),
style(local_hms()).dim(),
);
let _ = std::io::stderr().flush();
} else {
eprintln!(
" {} {} {} {}",
style("↻").cyan().bold(),
style(rebuilt).bold(),
style(format!("in {elapsed_str}")).dim(),
style(local_hms()).dim(),
);
}
self.last_was_rebuild = true;
self.last_was_block = false;
}
pub fn error(&mut self, msg: &str) {
self.commit_pending();
if !self.last_was_block {
eprintln!();
}
eprintln!(
" {} {} {msg}",
style("✘").red().bold(),
style("build error:").red().bold(),
);
eprintln!();
self.last_was_block = true;
}
pub fn warnings(&mut self, messages: &[String]) {
if messages.is_empty() {
return;
}
self.commit_pending();
for message in messages {
if !self.last_was_block {
eprintln!();
}
eprintln!(
" {} {} {message}",
style("⚠").yellow().bold(),
style("build warning:").yellow().bold(),
);
eprintln!();
self.last_was_block = true;
}
}
pub fn commit_pending(&mut self) {
if self.last_was_rebuild && self.interactive {
eprintln!();
}
self.last_was_rebuild = false;
}
}
fn format_elapsed(elapsed: Duration) -> String {
let ms = elapsed.as_millis();
if ms < 1_000 {
format!("{ms}ms")
} else {
format!("{:.1}s", elapsed.as_secs_f32())
}
}
fn format_trigger(triggers: &[PathBuf]) -> Option<String> {
let mut seen = HashSet::new();
let mut names: Vec<&str> = Vec::new();
for path in triggers {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if seen.insert(name) {
names.push(name);
}
}
}
match names.as_slice() {
[] => None,
[only] => Some((*only).to_string()),
[first, rest @ ..] => Some(format!("{first} (+{} more)", rest.len())),
}
}
#[must_use]
pub fn local_hms() -> String {
if let Ok(now) = time::OffsetDateTime::now_local() {
return format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second());
}
let now = time::OffsetDateTime::now_utc();
format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second())
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn local_hms_returns_eight_chars() {
let s = local_hms();
assert_eq!(s.len(), 8);
assert_eq!(s.as_bytes()[2], b':');
assert_eq!(s.as_bytes()[5], b':');
}
#[test]
fn reporter_tracks_pending_state() {
let mut r = RebuildReporter::new();
assert!(!r.last_was_rebuild);
r.success(Duration::from_millis(123), &[]);
assert!(r.last_was_rebuild);
r.commit_pending();
assert!(!r.last_was_rebuild);
}
#[test]
fn error_resets_pending() {
let mut r = RebuildReporter::new();
r.success(Duration::from_millis(50), &[]);
r.error("oops");
assert!(!r.last_was_rebuild);
}
#[test]
fn framed_blocks_track_state_for_blank_line_framing() {
let mut r = RebuildReporter::new();
r.error("boom");
assert!(r.last_was_block);
r.error("still boom");
assert!(r.last_was_block);
r.success(Duration::from_millis(5), &[]);
assert!(!r.last_was_block);
r.warnings(&["likely typo".to_string()]);
assert!(r.last_was_block);
r.warnings(&["another".to_string()]);
assert!(r.last_was_block);
r.success(Duration::from_millis(2), &[]);
assert!(!r.last_was_block);
}
#[test]
fn empty_warnings_keep_rolling_line() {
let mut r = RebuildReporter::new();
r.success(Duration::from_millis(50), &[]);
r.warnings(&[]);
assert!(r.last_was_rebuild);
}
#[test]
fn warnings_commit_pending_line() {
let mut r = RebuildReporter::new();
r.success(Duration::from_millis(50), &[]);
r.warnings(&["CSS token --colr-brand is a likely typo".to_string()]);
assert!(!r.last_was_rebuild);
}
#[test]
fn format_trigger_renders_file_names() {
assert_eq!(format_trigger(&[]), None);
assert_eq!(
format_trigger(&[PathBuf::from("/app/app-shell/app-shell.css")]),
Some("app-shell.css".to_string())
);
assert_eq!(
format_trigger(&[PathBuf::from("/app/a.css"), PathBuf::from("/app/sub/a.css"),]),
Some("a.css".to_string())
);
assert_eq!(
format_trigger(&[
PathBuf::from("/app/a.css"),
PathBuf::from("/app/b.html"),
PathBuf::from("/app/c.ts"),
]),
Some("a.css (+2 more)".to_string())
);
}
#[test]
fn format_elapsed_uses_ms_below_one_second() {
assert_eq!(format_elapsed(Duration::from_millis(0)), "0ms");
assert_eq!(format_elapsed(Duration::from_millis(7)), "7ms");
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
}
#[test]
fn format_elapsed_uses_seconds_at_or_above_one_second() {
assert_eq!(format_elapsed(Duration::from_millis(1_000)), "1.0s");
assert_eq!(format_elapsed(Duration::from_millis(1_250)), "1.2s");
assert_eq!(format_elapsed(Duration::from_secs(12)), "12.0s");
}
}