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};
pub(super) 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;
const CI_BAR_WIDTH: usize = 15;
pub(super) struct CiState {
phase: AtomicUsize,
pub(super) resolved: AtomicUsize,
pub(super) reused: AtomicUsize,
pub(super) downloaded: AtomicUsize,
pub(super) downloaded_bytes: AtomicU64,
pub(super) estimated_bytes: AtomicU64,
completed_at_fetch_start: AtomicUsize,
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),
estimated_bytes: AtomicU64::new(0),
completed_at_fetch_start: AtomicUsize::new(usize::MAX),
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) -> Snap {
let fetch_elapsed_ms = self
.fetch_start
.get()
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0);
let baseline = self.completed_at_fetch_start.load(Ordering::Relaxed);
Snap {
phase: self.phase.load(Ordering::Relaxed),
resolved: self.resolved.load(Ordering::Relaxed),
reused: self.reused.load(Ordering::Relaxed),
downloaded: self.downloaded.load(Ordering::Relaxed),
bytes: self.downloaded_bytes.load(Ordering::Relaxed),
estimated: self.estimated_bytes.load(Ordering::Relaxed),
fetch_elapsed_ms,
completed_at_fetch_start: if baseline == usize::MAX {
None
} else {
Some(baseline)
},
}
}
fn render(snap: Snap) -> String {
super::render::progress_line(snap, term_width(), CI_BAR_WIDTH)
}
fn render_header() -> String {
format!(
"{} {} {}",
style::emagenta("aube").bold(),
style::edim(crate::version::VERSION.as_str()),
style::edim("by en.dev"),
)
}
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.resolved == 0 || snap.phase == 0 {
continue;
}
let line = Self::render(snap);
if line.is_empty() {
continue;
}
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());
let completed =
self.reused.load(Ordering::Relaxed) + self.downloaded.load(Ordering::Relaxed);
let _ = self.completed_at_fetch_start.compare_exchange(
usize::MAX,
completed,
Ordering::Relaxed,
Ordering::Relaxed,
);
}
self.phase.store(n, Ordering::Relaxed);
}
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 final_bar = Self::render(snap);
if !final_bar.is_empty() {
let mut last = self.last_printed.lock().unwrap();
if *last != final_bar {
*last = final_bar.clone();
drop(last);
let _ = writeln!(std::io::stderr(), "{final_bar}");
}
}
let elapsed = self.start.elapsed();
let mut summary = format!(
"{} resolved {} · reused {}",
style::egreen("✓").bold(),
style::ebold(snap.resolved),
style::ebold(snap.reused),
);
if snap.downloaded > 0 || snap.bytes > 0 {
summary.push_str(&format!(" · downloaded {}", style::ebold(snap.downloaded)));
if snap.bytes > 0 {
summary.push_str(&format!(
" ({})",
style::edim(super::render::format_bytes(snap.bytes))
));
}
}
summary.push_str(&format!(" in {}", style::edim(format_duration(elapsed))));
let _ = writeln!(std::io::stderr(), "{summary}");
}
}
#[derive(Clone, Copy)]
pub(super) struct Snap {
pub(super) phase: usize,
pub(super) resolved: usize,
pub(super) reused: usize,
pub(super) downloaded: usize,
pub(super) bytes: u64,
pub(super) estimated: u64,
pub(super) fetch_elapsed_ms: u64,
pub(super) completed_at_fetch_start: Option<usize>,
}
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)
}
}