cli/src/doctor/progress.rs
139 lines
use std::io;use std::io::IsTerminal;use std::io::Write;use std::sync::Mutex;use std::time::Duration;use super::CheckStatus;/// Receives check lifecycle events while doctor builds the final report.////// Progress implementations must not write to stdout. The final report owns/// stdout so JSON and redirected human reports stay clean.pub(super) trait DoctorProgress: Send + Sync { fn begin(&self, label: &'static str); fn heartbeat(&self, label: &'static str, elapsed: Duration); fn finish(&self, label: &'static str, status: CheckStatus); fn settle(&self);}/// Selects the progress implementation for the current output mode.////// JSON output is always quiet so stdout remains valid JSON. Human output uses a/// transient stderr line only for interactive terminals, then clears it before/// the final report is printed.pub(super) fn doctor_progress(json: bool) -> std::sync::Arc<dyn DoctorProgress> { if should_show_progress( json, std::env::var("TERM").ok().as_deref(), io::stderr().is_terminal(), ) { std::sync::Arc::new(StderrProgress::default()) } else { std::sync::Arc::new(QuietProgress) }}fn should_show_progress(json: bool, term: Option<&str>, stderr_is_tty: bool) -> bool { !json && stderr_is_tty && term != Some("dumb")}struct QuietProgress;impl DoctorProgress for QuietProgress { fn begin(&self, _label: &'static str) {} fn heartbeat(&self, _label: &'static str, _elapsed: Duration) {} fn finish(&self, _label: &'static str, _status: CheckStatus) {} fn settle(&self) {}}#[derive(Default)]struct StderrProgress { state: Mutex<StderrProgressState>,}#[derive(Default)]struct StderrProgressState { wrote_line: bool,}impl StderrProgress { fn render(&self, message: String) { let Ok(mut state) = self.state.lock() else { return; }; let mut stderr = io::stderr().lock(); let _ = write!(stderr, "\r\x1b[2K{message}"); let _ = stderr.flush(); state.wrote_line = true; }}impl DoctorProgress for StderrProgress { fn begin(&self, label: &'static str) { self.render(format!("Checking {label}...")); } fn heartbeat(&self, label: &'static str, elapsed: Duration) { self.render(format!("Still checking {label}... {}s", elapsed.as_secs())); } fn finish(&self, _label: &'static str, _status: CheckStatus) {} fn settle(&self) { let Ok(mut state) = self.state.lock() else { return; }; if !state.wrote_line { return; } let mut stderr = io::stderr().lock(); let _ = write!(stderr, "\r\x1b[2K"); let _ = stderr.flush(); state.wrote_line = false; }}#[cfg(test)]mod tests { use super::*; #[test] fn progress_is_quiet_for_json() { assert!(!should_show_progress( /*json*/ true, Some("xterm-256color"), /*stderr_is_tty*/ true, )); } #[test] fn progress_is_quiet_for_non_tty() { assert!(!should_show_progress( /*json*/ false, Some("xterm-256color"), /*stderr_is_tty*/ false, )); } #[test] fn progress_is_quiet_for_dumb_terminal() { assert!(!should_show_progress( /*json*/ false, Some("dumb"), /*stderr_is_tty*/ true, )); } #[test] fn progress_is_shown_for_human_tty_output() { assert!(should_show_progress( /*json*/ false, Some("xterm-256color"), /*stderr_is_tty*/ true, )); }}