use clx::progress::{
ProgressJob, ProgressJobBuilder, ProgressJobDoneBehavior, ProgressOutput, ProgressStatus,
};
use clx::style;
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, Weak};
use std::thread;
use std::time::{Duration, Instant};
const CI_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
const TTY_MAX_VISIBLE_FETCH_ROWS: usize = 5;
fn overflow_fetch_label(count: usize) -> String {
let word = if count == 1 { "package" } else { "packages" };
format!("{count} more {word}…")
}
pub struct InstallProgress {
mode: Mode,
}
#[derive(Clone)]
enum Mode {
Tty {
root: Arc<ProgressJob>,
total: Arc<AtomicUsize>,
fetch_state: Arc<Mutex<FetchState>>,
},
Ci(Arc<CiState>),
}
struct FetchState {
visible: usize,
overflow: usize,
overflow_row: Option<Arc<ProgressJob>>,
}
impl Clone for InstallProgress {
fn clone(&self) -> Self {
if let Mode::Ci(s) = &self.mode {
s.alive.fetch_add(1, Ordering::Relaxed);
}
Self {
mode: self.mode.clone(),
}
}
}
impl InstallProgress {
pub fn try_new() -> Option<Self> {
if clx::progress::output() == ProgressOutput::Text {
return None;
}
if std::io::stderr().is_terminal() && !is_ci::cached() {
Some(Self::new_tty())
} else {
Some(Self::new_ci())
}
}
fn new_tty() -> Self {
let header = format!(
"{} {} {}",
style::emagenta("aube").bold(),
style::edim(env!("CARGO_PKG_VERSION")),
style::edim("by en.dev"),
);
let root = ProgressJobBuilder::new()
.body("{{aube}}{{phase}} {{progress_bar(flex=true)}} {{cur}}/{{total}}")
.body_text(Some("{{aube}}{{phase}} {{cur}}/{{total}}"))
.prop("aube", &header)
.prop("phase", "")
.progress_current(0)
.progress_total(0)
.on_done(ProgressJobDoneBehavior::Hide)
.start();
Self {
mode: Mode::Tty {
root,
total: Arc::new(AtomicUsize::new(0)),
fetch_state: Arc::new(Mutex::new(FetchState {
visible: 0,
overflow: 0,
overflow_row: None,
})),
},
}
}
fn new_ci() -> Self {
let state = Arc::new(CiState::new());
CiState::spawn_heartbeat(&state);
Self {
mode: Mode::Ci(state),
}
}
pub fn set_total(&self, total: usize) {
match &self.mode {
Mode::Tty { root, total: t, .. } => {
t.store(total, Ordering::Relaxed);
root.progress_total(total);
}
Mode::Ci(s) => {
s.resolved.store(total, Ordering::Relaxed);
}
}
}
pub fn inc_total(&self, n: usize) {
match &self.mode {
Mode::Tty { root, total, .. } => {
let new_total = total.fetch_add(n, Ordering::Relaxed) + n;
root.progress_total(new_total);
}
Mode::Ci(s) => {
s.resolved.fetch_add(n, Ordering::Relaxed);
}
}
}
pub fn set_phase(&self, phase: &str) {
match &self.mode {
Mode::Tty { root, .. } => {
if phase.is_empty() {
root.prop("phase", "");
} else {
root.prop("phase", &format!("{}", style::edim(format!(" — {phase}"))));
}
}
Mode::Ci(s) => s.set_phase(phase),
}
}
pub fn inc_reused(&self, n: usize) {
match &self.mode {
Mode::Tty { root, .. } => root.increment(n),
Mode::Ci(s) => {
s.reused.fetch_add(n, Ordering::Relaxed);
}
}
}
pub fn inc_downloaded_bytes(&self, bytes: u64) {
if let Mode::Ci(s) = &self.mode {
s.downloaded_bytes.fetch_add(bytes, Ordering::Relaxed);
}
}
pub fn start_fetch(&self, name: &str, version: &str) -> FetchRow {
match &self.mode {
Mode::Tty {
root, fetch_state, ..
} => {
let mut st = fetch_state.lock().unwrap();
if st.visible < TTY_MAX_VISIBLE_FETCH_ROWS {
st.visible += 1;
drop(st);
let child = ProgressJobBuilder::new()
.body(" {{spinner()}} {{label | flex}}")
.body_text(None::<String>)
.prop("label", &format!("{name}@{version}"))
.status(ProgressStatus::Running)
.on_done(ProgressJobDoneBehavior::Hide)
.build();
let child = root.add(child);
return FetchRow {
inner: FetchRowInner::Tty {
child,
root: Arc::downgrade(root),
fetch_state: Arc::downgrade(fetch_state),
visible: true,
},
completed: false,
};
}
st.overflow += 1;
if st.overflow_row.is_none() {
let row = ProgressJobBuilder::new()
.body(" {{spinner()}} {{label | flex}}")
.body_text(None::<String>)
.prop("label", &overflow_fetch_label(st.overflow))
.status(ProgressStatus::Running)
.on_done(ProgressJobDoneBehavior::Hide)
.build();
st.overflow_row = Some(root.add(row));
} else if let Some(row) = &st.overflow_row {
row.prop("label", &overflow_fetch_label(st.overflow));
}
FetchRow {
inner: FetchRowInner::Tty {
child: st.overflow_row.as_ref().unwrap().clone(),
root: Arc::downgrade(root),
fetch_state: Arc::downgrade(fetch_state),
visible: false,
},
completed: false,
}
}
Mode::Ci(s) => FetchRow {
inner: FetchRowInner::Ci(Arc::downgrade(s)),
completed: false,
},
}
}
pub fn finish(&self, print_ci_summary: bool) {
match &self.mode {
Mode::Tty { root, .. } => {
root.set_status(ProgressStatus::Done);
clx::progress::stop_clear();
}
Mode::Ci(s) => s.stop(print_ci_summary),
}
}
pub fn print_install_summary(
&self,
linked: usize,
top_level_linked: usize,
total_packages: usize,
elapsed: Duration,
) {
if linked == 0 && top_level_linked == 0 {
let word = if total_packages == 1 {
"package"
} else {
"packages"
};
let msg = if total_packages == 0 {
"Already up to date".to_string()
} else {
format!("Already up to date ({total_packages} {word})")
};
let line = format!(
"{} {} {} {} {}",
style::emagenta("aube").bold(),
style::edim(env!("CARGO_PKG_VERSION")),
style::edim("by en.dev"),
style::edim("·"),
style::egreen(msg).bold(),
);
let _ = writeln!(std::io::stderr(), "{line}");
return;
}
if linked == 0 {
return;
}
if !matches!(self.mode, Mode::Tty { .. }) {
return;
}
let word = if linked == 1 { "package" } else { "packages" };
let msg = format!(
"✓ installed {linked} {word} in {}",
format_duration(elapsed)
);
let line = format!(
"{} {} {} {} {}",
style::emagenta("aube").bold(),
style::edim(env!("CARGO_PKG_VERSION")),
style::edim("by en.dev"),
style::edim("·"),
style::egreen(msg).bold(),
);
let _ = writeln!(std::io::stderr(), "{line}");
}
}
impl Drop for InstallProgress {
fn drop(&mut self) {
match &self.mode {
Mode::Tty { root, .. } => {
if Arc::strong_count(root) == 1 {
root.set_status(ProgressStatus::Done);
clx::progress::stop_clear();
}
}
Mode::Ci(s) => {
if s.alive.fetch_sub(1, Ordering::Relaxed) == 1 {
s.stop(false);
}
}
}
}
}
pub struct FetchRow {
inner: FetchRowInner,
completed: bool,
}
enum FetchRowInner {
Tty {
child: Arc<ProgressJob>,
root: Weak<ProgressJob>,
fetch_state: Weak<Mutex<FetchState>>,
visible: bool,
},
Ci(Weak<CiState>),
}
impl FetchRow {
fn finish_inner(&mut self) {
if self.completed {
return;
}
self.completed = true;
match &self.inner {
FetchRowInner::Tty {
child,
root,
fetch_state,
visible,
} => {
if let Some(root) = root.upgrade() {
root.increment(1);
}
if *visible {
child.set_status(ProgressStatus::Done);
if let Some(st) = fetch_state.upgrade() {
let mut st = st.lock().unwrap();
if st.visible > 0 {
st.visible -= 1;
}
}
} else if let Some(st) = fetch_state.upgrade() {
let mut st = st.lock().unwrap();
if st.overflow > 0 {
st.overflow -= 1;
}
if st.overflow == 0 {
if let Some(row) = st.overflow_row.take() {
row.set_status(ProgressStatus::Done);
}
} else if let Some(row) = &st.overflow_row {
row.prop("label", &overflow_fetch_label(st.overflow));
}
}
}
FetchRowInner::Ci(weak) => {
if let Some(s) = weak.upgrade() {
s.downloaded.fetch_add(1, Ordering::Relaxed);
}
}
}
}
}
impl Drop for FetchRow {
fn drop(&mut self) {
self.finish_inner();
}
}
struct CiState {
phase: AtomicUsize,
resolved: AtomicUsize,
reused: AtomicUsize,
downloaded: AtomicUsize,
downloaded_bytes: AtomicU64,
start: Instant,
last_printed: Mutex<String>,
shown: AtomicBool,
done: AtomicBool,
alive: AtomicUsize,
wake: Condvar,
wake_lock: Mutex<()>,
heartbeat: Mutex<Option<thread::JoinHandle<()>>>,
}
const DEFAULT_BAR_WIDTH: usize = 80;
const MIN_BAR_WIDTH: usize = 40;
const MAX_BAR_WIDTH: usize = 120;
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 {
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(),
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) {
(
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),
)
}
fn render(snap: (usize, usize, usize, usize, u64)) -> String {
let (phase, resolved, reused, downloaded, bytes) = snap;
let completed = reused + downloaded;
let phase_str = if phase > 0 {
format!(" [{phase}/3]")
} else {
String::new()
};
let label = format!(
"{completed}/{resolved} pkgs{phase_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(env!("CARGO_PKG_VERSION")),
style::edim("by en.dev"),
);
render_centered_line(&header_text, term_width())
}
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);
}
fn set_phase(&self, phase: &str) {
let n = match phase {
"resolving" => 1,
"fetching" => 2,
"linking" => 3,
_ => return,
};
if self.phase.swap(n, Ordering::Relaxed) != n {
self.wake.notify_all();
}
}
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) = 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()),
);
}
}
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)
}
}
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}]")
}
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")
}
}
#[derive(Clone, Copy, Default)]
pub struct PausingWriter;
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for PausingWriter {
type Writer = PausingWriterGuard;
fn make_writer(&'a self) -> Self::Writer {
PausingWriterGuard { buf: Vec::new() }
}
}
pub struct PausingWriterGuard {
buf: Vec<u8>,
}
impl Write for PausingWriterGuard {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Drop for PausingWriterGuard {
fn drop(&mut self) {
if self.buf.is_empty() {
return;
}
let buf = std::mem::take(&mut self.buf);
let was_paused = clx::progress::is_paused();
if !was_paused {
clx::progress::pause();
}
let _: () = clx::progress::with_terminal_lock(|| {
let mut stderr = std::io::stderr().lock();
let _ = stderr.write_all(&buf);
let _ = stderr.flush();
});
if !was_paused {
clx::progress::resume();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overflow_fetch_label_pluralizes_count() {
assert_eq!(overflow_fetch_label(1), "1 more package…");
assert_eq!(overflow_fetch_label(2), "2 more packages…");
}
}