use std::io::IsTerminal;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
fn multi() -> &'static MultiProgress {
static M: OnceLock<MultiProgress> = OnceLock::new();
M.get_or_init(MultiProgress::new)
}
#[derive(Copy, Clone, Debug)]
enum Mode {
Curated,
Verbose,
Tui,
}
fn mode() -> Mode {
static MODE: OnceLock<Mode> = OnceLock::new();
*MODE.get_or_init(|| {
if is_verbose() {
Mode::Verbose
} else if is_tui() {
Mode::Tui
} else {
Mode::Curated
}
})
}
pub fn is_verbose() -> bool {
std::env::var("WHISKER_VERBOSE")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
pub fn is_tui() -> bool {
std::env::var("WHISKER_TUI")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
fn is_tty() -> bool {
static TTY: OnceLock<bool> = OnceLock::new();
*TTY.get_or_init(|| std::io::stderr().is_terminal())
}
static LAST_STATUS: Mutex<Option<String>> = Mutex::new(None);
pub fn ensure_status(_label: impl Into<String>) {
if matches!(mode(), Mode::Tui) {
return;
}
if let Ok(mut guard) = LAST_STATUS.lock() {
*guard = Some(String::new());
}
}
pub fn set_status(msg: impl Into<String>) {
if matches!(mode(), Mode::Tui) {
return;
}
let m = msg.into();
let m_for_dedupe = m.clone();
if let Ok(mut guard) = LAST_STATUS.lock() {
if guard.as_ref() == Some(&m_for_dedupe) {
return;
}
*guard = Some(m_for_dedupe);
}
info(format!("dev-server · {m}"));
}
pub fn finish_status(final_msg: impl Into<String>) {
if matches!(mode(), Mode::Tui) {
return;
}
info(format!("dev-server · {}", final_msg.into()));
}
pub fn section(name: &str) {
match mode() {
Mode::Verbose => {
eprintln!("[whisker] ─── {name} ───");
}
Mode::Curated | Mode::Tui => {
let bar_chars = "─".repeat(40usize.saturating_sub(name.len()));
let line = if is_tty() {
format!("\n\x1b[1;36m──── {name} {bar_chars}\x1b[0m")
} else {
format!("\n──── {name} {bar_chars}")
};
emit_above_bars(&line);
}
}
}
fn indicatif_active() -> bool {
matches!(mode(), Mode::Curated) && is_tty()
}
fn emit_above_bars(line: &str) {
if !indicatif_active() {
eprintln!("{line}");
return;
}
let line_owned = line.to_string();
multi().suspend(|| {
eprintln!("{line_owned}");
});
}
pub struct Step {
bar: Option<ProgressBar>,
started_at: Instant,
name: String,
detail: String,
}
impl Step {
pub fn done(self, summary: impl Into<String>) {
self.finish(StepKind::Done, &summary.into());
}
pub fn fail(self, summary: impl Into<String>) {
self.finish(StepKind::Fail, &summary.into());
}
pub fn pipe(
&self,
cmd: &mut std::process::Command,
) -> std::io::Result<std::process::ExitStatus> {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
let _child_guard = crate::child_guard::track(child.id());
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let bar_stdout = self.bar.clone();
let bar_stderr = self.bar.clone();
let t_out = std::thread::spawn(move || stream_through_bar(stdout, bar_stdout));
let t_err = std::thread::spawn(move || stream_through_bar(stderr, bar_stderr));
let status = child.wait()?;
let _ = t_out.join();
let _ = t_err.join();
Ok(status)
}
fn finish(self, kind: StepKind, summary: &str) {
let elapsed = format_elapsed(self.started_at.elapsed());
let summary = if summary.is_empty() {
elapsed
} else {
format!("{summary} {elapsed}")
};
let glyph = kind.glyph();
let line = render_step_line(glyph, &self.name, &self.detail, &summary, kind);
if let Some(bar) = self.bar {
bar.set_style(
ProgressStyle::with_template("{msg}").expect("template literal is valid"),
);
bar.finish_with_message(line);
} else {
if matches!(mode(), Mode::Tui) {
eprintln!("{TUI_STEP_END_MARKER}");
}
eprintln!("{line}");
}
}
}
pub const TUI_STEP_START_MARKER: &str = "\x1eWHISKER-TUI-STEP-START";
pub const TUI_STEP_END_MARKER: &str = "\x1eWHISKER-TUI-STEP-END";
fn stream_through_bar<R: std::io::Read + Send + 'static>(
stream: Option<R>,
bar: Option<ProgressBar>,
) {
use std::io::{BufRead, BufReader};
let Some(s) = stream else { return };
let reader = BufReader::new(s);
for line in reader.lines().map_while(Result::ok) {
if let Some(progress) = subprocess_progress_text(&line) {
if let Some(bar) = &bar {
bar.set_message(progress.to_string());
bar.tick();
}
else if matches!(mode(), Mode::Verbose) {
eprintln!("[whisker] {line}");
}
} else if !line.is_empty() {
if matches!(mode(), Mode::Curated | Mode::Tui) && is_subprocess_noise(&line) {
continue;
}
if bar.is_some() {
let line_owned = line.clone();
multi().suspend(|| {
eprintln!("{line_owned}");
});
} else {
eprintln!("{line}");
}
}
}
}
fn subprocess_progress_text(line: &str) -> Option<String> {
if let Some(s) = cargo_progress_text(line) {
return Some(s.to_string());
}
gradle_progress_text(line)
}
fn cargo_progress_text(line: &str) -> Option<&str> {
let stripped = strip_leading_ansi(line.trim_start());
let first_word = stripped.split_whitespace().next()?;
if matches!(
first_word,
"Compiling"
| "Checking"
| "Finished"
| "Updating"
| "Downloading"
| "Downloaded"
| "Fresh"
| "Locking"
| "Building"
| "Documenting"
| "Generating"
| "Installing"
| "Removing"
| "Compiled"
) {
Some(stripped.trim_end())
} else {
None
}
}
fn gradle_progress_text(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("> Task ") {
return Some(format!("gradle: {rest}"));
}
if trimmed.starts_with("BUILD SUCCESSFUL") || trimmed.starts_with("BUILD FAILED") {
return Some(format!("gradle: {trimmed}"));
}
if trimmed.contains(" actionable task") {
return Some(format!("gradle: {trimmed}"));
}
None
}
fn is_subprocess_noise(line: &str) -> bool {
let t = line.trim();
if t.is_empty() {
return true;
}
const GRADLE_NOISE_PREFIXES: &[&str] = &[
"To honour the JVM settings for this build",
"Daemon will be stopped at the end of the build",
"Deprecated Gradle features were used in this build",
"You can use '--warning-mode all'",
"For more on this, please refer to",
];
for prefix in GRADLE_NOISE_PREFIXES {
if t.starts_with(prefix) {
return true;
}
}
false
}
fn strip_leading_ansi(s: &str) -> &str {
let bytes = s.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() && bytes[i] == 0x1b && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
j += 1;
}
if j < bytes.len() {
i = j + 1;
} else {
break;
}
}
&s[i..]
}
#[derive(Copy, Clone)]
enum StepKind {
Done,
Fail,
}
impl StepKind {
fn glyph(&self) -> &'static str {
match self {
StepKind::Done => "✓",
StepKind::Fail => "✗",
}
}
}
pub fn step(name: impl Into<String>, detail: impl Into<String>) -> Step {
let name = name.into();
let detail = detail.into();
let started_at = Instant::now();
match mode() {
Mode::Verbose => {
eprintln!("[whisker] ⏵ {name}: {detail}");
Step {
bar: None,
started_at,
name,
detail,
}
}
Mode::Tui => {
eprintln!("{TUI_STEP_START_MARKER}\x1e{name}\x1e{detail}");
Step {
bar: None,
started_at,
name,
detail,
}
}
Mode::Curated if is_tty() => {
let bar = ProgressBar::new_spinner();
bar.set_style(
ProgressStyle::with_template(" {spinner:.cyan} {prefix:<12} {msg:<24} …")
.expect("template literal is valid"),
);
bar.set_prefix(name.clone());
bar.set_message(detail.clone());
let bar = multi().add(bar);
bar.tick();
Step {
bar: Some(bar),
started_at,
name,
detail,
}
}
Mode::Curated => {
eprintln!(" ⏵ {name:<12} {detail}");
Step {
bar: None,
started_at,
name,
detail,
}
}
}
}
fn render_step_line(
glyph: &str,
name: &str,
detail: &str,
summary: &str,
kind: StepKind,
) -> String {
if is_tty() {
let color = match kind {
StepKind::Done => "\x1b[32m",
StepKind::Fail => "\x1b[31m",
};
format!(" {color}{glyph}\x1b[0m {name:<12} {detail:<24} {summary}")
} else {
format!(" {glyph} {name:<12} {detail:<24} {summary}")
}
}
fn format_elapsed(d: Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
format!("{ms}ms")
} else if ms < 60_000 {
format!("{:.1}s", d.as_secs_f64())
} else {
let total_secs = d.as_secs();
format!("{}m{:02}s", total_secs / 60, total_secs % 60)
}
}
pub fn info(msg: impl AsRef<str>) {
let m = msg.as_ref();
match mode() {
Mode::Verbose => eprintln!("[whisker] {m}"),
Mode::Curated | Mode::Tui => {
if is_tty() {
emit_above_bars(&format!(" \x1b[90m·\x1b[0m {m}"));
} else {
eprintln!(" · {m}");
}
}
}
}
pub fn warn(msg: impl AsRef<str>) {
let m = msg.as_ref();
match mode() {
Mode::Verbose => eprintln!("[whisker] warn: {m}"),
Mode::Curated | Mode::Tui => {
if is_tty() {
emit_above_bars(&format!(" \x1b[33m⚠\x1b[0m {m}"));
} else {
eprintln!(" ! {m}");
}
}
}
}
pub fn debug(msg: impl AsRef<str>) {
match mode() {
Mode::Verbose => {
let m = msg.as_ref();
eprintln!("[whisker] debug: {m}");
}
Mode::Curated | Mode::Tui => {}
}
}
pub fn error(msg: impl AsRef<str>) {
let m = msg.as_ref();
match mode() {
Mode::Verbose => eprintln!("[whisker] error: {m}"),
Mode::Curated | Mode::Tui => {
if is_tty() {
emit_above_bars(&format!(" \x1b[31m✗\x1b[0m {m}"));
} else {
eprintln!(" X {m}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_elapsed_chooses_unit_by_magnitude() {
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
assert_eq!(format_elapsed(Duration::from_millis(1_000)), "1.0s");
assert_eq!(format_elapsed(Duration::from_millis(6_750)), "6.8s");
assert_eq!(format_elapsed(Duration::from_secs(125)), "2m05s");
}
#[test]
fn step_kind_glyphs_are_recognisable_ascii() {
assert_eq!(StepKind::Done.glyph(), "✓");
assert_eq!(StepKind::Fail.glyph(), "✗");
}
#[test]
fn render_step_line_aligns_name_column_at_12_chars() {
std::env::set_var("WHISKER_VERBOSE", "");
let line = if is_tty() {
return;
} else {
render_step_line("✓", "compile", "hello-world", "6.7s", StepKind::Done)
};
assert!(line.contains("✓"));
assert!(line.contains("compile"));
assert!(line.contains("hello-world"));
assert!(line.contains("6.7s"));
}
#[test]
fn cargo_progress_recognised_with_leading_whitespace() {
assert_eq!(
cargo_progress_text(" Compiling foo v0.1.0"),
Some("Compiling foo v0.1.0"),
);
assert_eq!(
cargo_progress_text(" Finished `release` target(s) in 12.3s"),
Some("Finished `release` target(s) in 12.3s"),
);
}
#[test]
fn cargo_progress_rejects_diagnostics_and_user_output() {
assert!(cargo_progress_text("error[E0277]: ...").is_none());
assert!(cargo_progress_text("warning: unused").is_none());
assert!(cargo_progress_text("user println output").is_none());
}
#[test]
fn gradle_task_lines_fold_into_progress() {
assert_eq!(
gradle_progress_text("> Task :app:assembleDebug"),
Some("gradle: :app:assembleDebug".to_string()),
);
assert_eq!(
gradle_progress_text("> Task :app:assembleDebug UP-TO-DATE"),
Some("gradle: :app:assembleDebug UP-TO-DATE".to_string()),
);
assert_eq!(
gradle_progress_text("> Task :whisker-image:mergeDebugJniLibFolders NO-SOURCE"),
Some("gradle: :whisker-image:mergeDebugJniLibFolders NO-SOURCE".to_string()),
);
}
#[test]
fn gradle_build_terminal_status_recognised() {
assert_eq!(
gradle_progress_text("BUILD SUCCESSFUL in 18s"),
Some("gradle: BUILD SUCCESSFUL in 18s".to_string()),
);
assert_eq!(
gradle_progress_text("BUILD FAILED in 1m 12s"),
Some("gradle: BUILD FAILED in 1m 12s".to_string()),
);
assert_eq!(
gradle_progress_text("137 actionable tasks: 6 executed, 131 up-to-date"),
Some("gradle: 137 actionable tasks: 6 executed, 131 up-to-date".to_string()),
);
}
#[test]
fn gradle_progress_rejects_non_gradle_lines() {
assert!(gradle_progress_text("Compiling foo v0.1.0").is_none());
assert!(gradle_progress_text("regular line").is_none());
assert!(gradle_progress_text("> Configure project :app").is_none());
}
#[test]
fn subprocess_progress_combines_both_recognisers() {
assert!(subprocess_progress_text(" Compiling foo v0.1.0").is_some());
assert!(subprocess_progress_text("> Task :app:assembleDebug").is_some());
assert!(subprocess_progress_text("BUILD SUCCESSFUL in 18s").is_some());
assert!(subprocess_progress_text("regular diagnostic line").is_none());
}
#[test]
fn subprocess_noise_filters_gradle_daemon_advisory() {
assert!(is_subprocess_noise(
"To honour the JVM settings for this build a single-use Daemon process will be forked. ..."
));
assert!(is_subprocess_noise(
"Daemon will be stopped at the end of the build"
));
assert!(is_subprocess_noise(
"Deprecated Gradle features were used in this build, making it incompatible ..."
));
assert!(is_subprocess_noise(
"You can use '--warning-mode all' to show the individual deprecation warnings ..."
));
assert!(is_subprocess_noise(
"For more on this, please refer to https://docs.gradle.org/..."
));
}
#[test]
fn subprocess_noise_leaves_real_diagnostics_alone() {
assert!(!is_subprocess_noise(
"FAILURE: Build failed with an exception."
));
assert!(!is_subprocess_noise("* What went wrong:"));
assert!(!is_subprocess_noise("error: linker `cc` not found"));
assert!(!is_subprocess_noise(
"> Task :app:compileDebugJavaWithJavac FAILED"
));
}
}