use clx::style;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
const CI_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
const DEFAULT_BAR_WIDTH: usize = 80;
const MIN_BAR_WIDTH: usize = 40;
const MAX_BAR_WIDTH: usize = 120;
pub(super) struct CiState {
phase: AtomicUsize,
pub(super) resolved: AtomicUsize,
pub(super) reused: AtomicUsize,
pub(super) downloaded: AtomicUsize,
pub(super) downloaded_bytes: AtomicU64,
start: Instant,
fetch_start: OnceLock<Instant>,
last_printed: Mutex<String>,
pub(super) shown: AtomicBool,
done: AtomicBool,
pub(super) alive: AtomicUsize,
wake: Condvar,
wake_lock: Mutex<()>,
heartbeat: Mutex<Option<thread::JoinHandle<()>>>,
}
pub(super) fn term_width() -> usize {
let raw = std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.or_else(|| {
let (_rows, cols) = console::Term::stderr().size();
if cols == 0 { None } else { Some(cols as usize) }
})
.unwrap_or(DEFAULT_BAR_WIDTH);
raw.clamp(MIN_BAR_WIDTH, MAX_BAR_WIDTH)
}
impl CiState {
pub(super) fn new() -> Self {
Self {
phase: AtomicUsize::new(0),
resolved: AtomicUsize::new(0),
reused: AtomicUsize::new(0),
downloaded: AtomicUsize::new(0),
downloaded_bytes: AtomicU64::new(0),
start: Instant::now(),
fetch_start: OnceLock::new(),
last_printed: Mutex::new(String::new()),
shown: AtomicBool::new(false),
done: AtomicBool::new(false),
alive: AtomicUsize::new(1),
wake: Condvar::new(),
wake_lock: Mutex::new(()),
heartbeat: Mutex::new(None),
}
}
fn snapshot(&self) -> (usize, usize, usize, usize, u64, u64, u64) {
let fetch_elapsed_ms = self
.fetch_start
.get()
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0);
(
self.phase.load(Ordering::Relaxed),
self.resolved.load(Ordering::Relaxed),
self.reused.load(Ordering::Relaxed),
self.downloaded.load(Ordering::Relaxed),
self.downloaded_bytes.load(Ordering::Relaxed),
self.start.elapsed().as_millis() as u64,
fetch_elapsed_ms,
)
}
fn render(snap: (usize, usize, usize, usize, u64, u64, u64)) -> String {
let (phase, resolved, reused, downloaded, bytes, elapsed_ms, fetch_elapsed_ms) = snap;
let completed = reused + downloaded;
let phase_str = if phase > 0 {
format!(" [{phase}/3]")
} else {
String::new()
};
let rate_str = if phase == 2 && bytes > 0 && fetch_elapsed_ms > 0 {
let rate = bytes.saturating_mul(1000) / fetch_elapsed_ms;
format!(" · {}/s", format_bytes(rate))
} else {
String::new()
};
let elapsed_str = format!(" · {}", format_duration(Duration::from_millis(elapsed_ms)));
let label = format!(
"{completed}/{resolved} pkgs{phase_str} · {}{rate_str}{elapsed_str}",
format_bytes(bytes)
);
render_bar_with_label(completed, resolved, term_width(), &label)
}
fn render_header() -> String {
let header_text = format!(
"{} {} {}",
style::emagenta("aube").bold(),
style::edim(crate::version::VERSION.as_str()),
style::edim("by en.dev"),
);
render_centered_line(&header_text, term_width())
}
pub(super) fn spawn_heartbeat(state: &Arc<Self>) {
let thread_state = state.clone();
let handle = thread::spawn(move || {
let state = thread_state;
loop {
let guard = state.wake_lock.lock().unwrap();
if state.done.load(Ordering::Relaxed) {
break;
}
let (guard, _timeout) = state
.wake
.wait_timeout(guard, CI_HEARTBEAT_INTERVAL)
.unwrap();
drop(guard);
if state.done.load(Ordering::Relaxed) {
break;
}
let snap = state.snapshot();
if snap.1 == 0 {
continue;
}
let line = Self::render(snap);
let mut last = state.last_printed.lock().unwrap();
if *last == line {
continue;
}
*last = line.clone();
drop(last);
if !state.shown.swap(true, Ordering::Relaxed) {
let _ = writeln!(std::io::stderr(), "{}", Self::render_header());
}
let _ = writeln!(std::io::stderr(), "{line}");
}
});
*state.heartbeat.lock().unwrap() = Some(handle);
}
pub(super) fn set_phase(&self, phase: &str) {
let n = match phase {
"resolving" => 1,
"fetching" => 2,
"linking" => 3,
_ => return,
};
if n == 2 {
let _ = self.fetch_start.set(Instant::now());
}
if self.phase.swap(n, Ordering::Relaxed) != n {
self.wake.notify_all();
}
}
pub(super) fn stop(&self, print_summary: bool) {
if self.done.swap(true, Ordering::Relaxed) {
return;
}
self.wake.notify_all();
if let Some(handle) = self.heartbeat.lock().unwrap().take() {
let _ = handle.join();
}
if !print_summary {
return;
}
if !self.shown.load(Ordering::Relaxed) {
return;
}
let snap = self.snapshot();
let line = Self::render(snap);
let mut last = self.last_printed.lock().unwrap();
if *last != line {
*last = line.clone();
drop(last);
let _ = writeln!(std::io::stderr(), "{line}");
}
let (_phase, resolved, reused, downloaded, bytes, _elapsed_ms, _fetch_elapsed_ms) = snap;
let elapsed = self.start.elapsed();
let summary = format!(
"{} {} · resolved {} · reused {} · downloaded {} ({})",
style::egreen("✓"),
style::edim(format_duration(elapsed)),
resolved,
reused,
downloaded,
format_bytes(bytes),
);
let _ = writeln!(
std::io::stderr(),
"{}",
render_centered_line(&summary, term_width()),
);
}
}
pub(super) fn format_duration(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 = d.as_secs();
format!("{}m{:02}s", total / 60, total % 60)
}
}
pub(super) fn render_centered_line(text: &str, outer_width: usize) -> String {
let outer_width = outer_width.max(MIN_BAR_WIDTH);
let inner_width = outer_width.saturating_sub(2);
let text_width = console::measure_text_width(text);
if text_width >= inner_width {
return format!("[{text}]");
}
let pad = inner_width - text_width;
let left = pad / 2;
let right = pad - left;
format!("[{}{text}{}]", " ".repeat(left), " ".repeat(right))
}
fn render_bar_with_label(current: usize, total: usize, outer_width: usize, label: &str) -> String {
let outer_width = outer_width.max(MIN_BAR_WIDTH);
let inner_width = outer_width.saturating_sub(2);
let padded = format!(" {label} ");
let padded_chars: Vec<char> = padded.chars().collect();
let label_len = padded_chars.len().min(inner_width);
let label_start = inner_width.saturating_sub(label_len) / 2;
let label_end = label_start + label_len;
let filled = current
.checked_mul(inner_width)
.and_then(|value| value.checked_div(total))
.unwrap_or(0)
.min(inner_width);
let mut body = String::with_capacity(inner_width);
for i in 0..inner_width {
if i >= label_start && i < label_end {
body.push(padded_chars[i - label_start]);
} else if i < filled {
body.push('#');
} else {
body.push('-');
}
}
format!("[{body}]")
}
pub(super) fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1_000;
const MB: u64 = 1_000_000;
const GB: u64 = 1_000_000_000;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.0} kB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}