mod celebrate;
mod compile;
mod pipeline;
mod spinner;
mod transfer;
use crate::ui::OutputContext;
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
pub use celebrate::{ArtifactSummary, CelebrationSummary, CompletionCelebration};
pub use compile::{BuildPhase, BuildProfile, CompilationProgress, CrateInfo};
pub use pipeline::{PipelineProgress, PipelineStage, StageStatus};
pub use spinner::{AnimatedSpinner, SpinnerResult, SpinnerStyle};
pub use transfer::{TransferDirection, TransferProgress, TransferStats};
const DEFAULT_TERMINAL_WIDTH: u16 = 80;
const MAX_UPDATES_PER_SEC: u32 = 10;
const CLEAR_LINE: &str = "\r\x1b[2K";
const HIDE_CURSOR: &str = "\x1b[?25l";
const SHOW_CURSOR: &str = "\x1b[?25h";
static ACTIVE_CONTEXTS: AtomicUsize = AtomicUsize::new(0);
static RENDER_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug)]
struct SignalState {
interrupted: AtomicBool,
resized: AtomicBool,
}
impl SignalState {
fn new() -> Self {
Self {
interrupted: AtomicBool::new(false),
resized: AtomicBool::new(false),
}
}
fn mark_interrupted(&self) {
self.interrupted.store(true, Ordering::SeqCst);
}
fn mark_resized(&self) {
self.resized.store(true, Ordering::SeqCst);
}
fn take_resized(&self) -> bool {
self.resized.swap(false, Ordering::SeqCst)
}
#[cfg(test)]
fn simulate_interrupt(&self) {
self.mark_interrupted();
}
#[cfg(test)]
fn simulate_resize(&self) {
self.mark_resized();
}
}
static SIGNAL_STATE: OnceLock<std::sync::Arc<SignalState>> = OnceLock::new();
fn ensure_signal_state() -> std::sync::Arc<SignalState> {
SIGNAL_STATE
.get_or_init(|| {
let state = std::sync::Arc::new(SignalState::new());
start_signal_listener(state.clone());
state
})
.clone()
}
fn start_signal_listener(state: std::sync::Arc<SignalState>) {
#[cfg(unix)]
{
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let Ok(runtime) = runtime else {
return;
};
runtime.block_on(async move {
use tokio::signal::unix::{SignalKind, signal};
let mut sigint = match signal(SignalKind::interrupt()) {
Ok(sig) => sig,
Err(_) => return,
};
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(sig) => sig,
Err(_) => return,
};
let mut sigwinch = match signal(SignalKind::window_change()) {
Ok(sig) => sig,
Err(_) => return,
};
loop {
tokio::select! {
_ = sigint.recv() => {
state.mark_interrupted();
cleanup_terminal_if_active();
}
_ = sigterm.recv() => {
state.mark_interrupted();
cleanup_terminal_if_active();
}
_ = sigwinch.recv() => {
state.mark_resized();
}
}
}
});
});
}
}
fn cleanup_terminal_if_active() {
if ACTIVE_CONTEXTS.load(Ordering::SeqCst) == 0 {
return;
}
let _lock = RENDER_LOCK.lock();
let mut buffer = String::new();
buffer.push_str(CLEAR_LINE);
buffer.push_str(SHOW_CURSOR);
let _ = write_stderr(&buffer);
}
fn write_stderr(text: &str) -> std::io::Result<()> {
let mut stderr = std::io::stderr();
stderr.write_all(text.as_bytes())?;
stderr.flush()
}
fn detect_terminal_width_with<F>(get_env: F) -> u16
where
F: Fn(&str) -> Option<String>,
{
get_env("COLUMNS")
.and_then(|value| value.parse::<u16>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_TERMINAL_WIDTH)
}
fn detect_terminal_width() -> u16 {
detect_terminal_width_with(|key| std::env::var(key).ok())
}
#[derive(Debug)]
pub struct RateLimiter {
start: Instant,
min_interval_ns: u64,
last_ns: AtomicU64,
}
impl RateLimiter {
#[must_use]
pub fn new(max_per_sec: u32) -> Self {
let per_sec = max_per_sec.max(1) as u64;
let min_interval_ns = 1_000_000_000u64 / per_sec;
Self {
start: Instant::now(),
min_interval_ns,
last_ns: AtomicU64::new(u64::MAX),
}
}
pub fn allow(&self) -> bool {
let now_ns = self.now_ns();
self.allow_with(now_ns)
}
pub fn reset(&self) {
self.last_ns.store(u64::MAX, Ordering::SeqCst);
}
fn now_ns(&self) -> u64 {
let elapsed = self.start.elapsed();
let nanos = elapsed.as_nanos();
nanos.min(u128::from(u64::MAX)) as u64
}
fn allow_with(&self, now_ns: u64) -> bool {
let last = self.last_ns.load(Ordering::Relaxed);
if last == u64::MAX {
return self
.last_ns
.compare_exchange(u64::MAX, now_ns, Ordering::SeqCst, Ordering::Relaxed)
.is_ok();
}
if now_ns.saturating_sub(last) < self.min_interval_ns {
return false;
}
self.last_ns
.compare_exchange(last, now_ns, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
}
#[cfg(test)]
fn allow_at(&self, now_ns: u64) -> bool {
self.allow_with(now_ns)
}
#[cfg(test)]
fn min_interval_ns(&self) -> u64 {
self.min_interval_ns
}
}
#[derive(Debug)]
struct TerminalState {
width: u16,
#[allow(dead_code)]
cursor_hidden: bool,
}
impl TerminalState {
fn new() -> Self {
Self {
width: detect_terminal_width(),
cursor_hidden: false,
}
}
fn refresh_width(&mut self) {
self.width = detect_terminal_width();
}
fn truncate(&self, line: &str) -> String {
let width = self.width.max(1) as usize;
line.chars().take(width).collect()
}
#[cfg(test)]
fn refresh_width_with<F>(&mut self, get_env: F)
where
F: Fn(&str) -> Option<String>,
{
self.width = detect_terminal_width_with(get_env);
}
}
#[derive(Debug)]
struct CleanupGuard {
enabled: bool,
}
impl CleanupGuard {
fn new(enabled: bool) -> Self {
Self { enabled }
}
fn clear_line(&self) {
if self.enabled {
let _lock = RENDER_LOCK.lock();
let _ = write_stderr(CLEAR_LINE);
}
}
fn hide_cursor(&self) {
if self.enabled {
let _lock = RENDER_LOCK.lock();
let _ = write_stderr(HIDE_CURSOR);
}
}
fn show_cursor(&self) {
if self.enabled {
let _lock = RENDER_LOCK.lock();
let _ = write_stderr(SHOW_CURSOR);
}
}
}
#[derive(Debug)]
pub struct ProgressContext {
rate_limiter: RateLimiter,
terminal: TerminalState,
cleanup_guard: CleanupGuard,
signal_state: Option<std::sync::Arc<SignalState>>,
enabled: bool,
}
impl ProgressContext {
#[must_use]
pub fn new(ctx: OutputContext) -> Self {
let stderr_is_tty = std::io::stderr().is_terminal();
Self::new_with_options(ctx, stderr_is_tty, true)
}
fn new_with_options(ctx: OutputContext, stderr_is_tty: bool, listen_signals: bool) -> Self {
let enabled = stderr_is_tty && matches!(ctx, OutputContext::Interactive);
let cleanup_guard = CleanupGuard::new(enabled);
if enabled {
let previous = ACTIVE_CONTEXTS.fetch_add(1, Ordering::SeqCst);
if previous == 0 {
cleanup_guard.hide_cursor();
}
}
let signal_state = if enabled && listen_signals {
Some(ensure_signal_state())
} else {
None
};
Self {
rate_limiter: RateLimiter::new(MAX_UPDATES_PER_SEC),
terminal: TerminalState::new(),
cleanup_guard,
signal_state,
enabled,
}
}
pub fn render(&mut self, line: &str) {
if !self.enabled {
return;
}
if !self.rate_limiter.allow() {
return;
}
if let Some(state) = &self.signal_state {
if state.interrupted.load(Ordering::SeqCst) {
self.cleanup_guard.clear_line();
self.cleanup_guard.show_cursor();
return;
}
if state.take_resized() {
self.terminal.refresh_width();
}
}
let rendered = self.terminal.truncate(line);
let _lock = RENDER_LOCK.lock();
let mut buffer = String::new();
buffer.push_str(CLEAR_LINE);
buffer.push_str(&rendered);
let _ = write_stderr(&buffer);
}
pub fn clear(&self) {
self.cleanup_guard.clear_line();
}
#[cfg(test)]
fn new_for_test(stderr_is_tty: bool) -> Self {
Self::new_with_options(OutputContext::Interactive, stderr_is_tty, false)
}
}
impl Drop for ProgressContext {
fn drop(&mut self) {
if !self.enabled {
return;
}
let previous = ACTIVE_CONTEXTS.fetch_sub(1, Ordering::SeqCst);
if previous == 1 {
self.cleanup_guard.clear_line();
self.cleanup_guard.show_cursor();
}
}
}
#[cfg(test)]
mod tests;