use std::{
io::{self, IsTerminal, Write},
sync::Mutex,
};
use objects::{Progress, ProgressSnapshot, Sink};
use repo::Repository;
use crate::cli::{Cli, should_output_json, style};
pub(crate) const COMMIT_TICK_INTERVAL: usize = 64;
pub(crate) fn progress_for(cli: &Cli, repo: &Repository) -> Progress {
if should_output_json(cli, Some(repo.config())) {
Progress::null()
} else {
Progress::with_sink(Box::new(TerminalSink::new()))
}
}
pub(crate) struct TerminalSink {
state: Mutex<RenderState>,
}
#[derive(Default)]
struct RenderState {
last_phase: Option<String>,
painted: bool,
}
impl TerminalSink {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(RenderState::default()),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, RenderState> {
self.state.lock().unwrap_or_else(|p| p.into_inner())
}
fn should_repaint(state: &RenderState, snap: &ProgressSnapshot) -> bool {
let phase_changed = state.last_phase.as_deref() != Some(snap.phase.as_str());
phase_changed
|| snap.done == 0
|| (snap.total != 0 && snap.done == snap.total)
|| snap.done.is_multiple_of(COMMIT_TICK_INTERVAL)
}
fn format_line(snap: &ProgressSnapshot) -> String {
if snap.done > 0 && snap.total > 0 {
let pct = snap.done.saturating_mul(100) / snap.total;
format!("{} ({}/{}, {}%)", snap.phase, snap.done, snap.total, pct)
} else {
snap.phase.clone()
}
}
fn paint(line: &str) {
if io::stdout().is_terminal() {
print!("\r{}\x1b[K", style::dim(line));
io::stdout().flush().ok();
} else {
println!("{}", style::dim(line));
}
}
}
impl Sink for TerminalSink {
fn render(&self, snap: ProgressSnapshot) {
if snap.phase.is_empty() {
return;
}
let mut state = self.lock();
if !Self::should_repaint(&state, &snap) {
return;
}
Self::paint(&Self::format_line(&snap));
state.last_phase = Some(snap.phase);
state.painted = true;
}
}
pub(crate) fn clear_line(progress: &Progress) {
if !progress.is_active() {
return;
}
if io::stdout().is_terminal() {
print!("\r\x1b[K");
io::stdout().flush().ok();
}
}
pub(crate) fn finish_line(progress: &Progress, message: &str) {
if !progress.is_active() {
return;
}
if io::stdout().is_terminal() {
print!("\r\x1b[K{}\n", style::accent(message));
io::stdout().flush().ok();
} else {
println!("{}", style::accent(message));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repaints_on_phase_change_regardless_of_throttle() {
let mut state = RenderState {
last_phase: Some("old".into()),
painted: true,
};
let snap = ProgressSnapshot {
done: 7, total: 100,
phase: "new".into(),
};
assert!(TerminalSink::should_repaint(&state, &snap));
state.last_phase = Some("new".into());
assert!(!TerminalSink::should_repaint(&state, &snap));
}
#[test]
fn count_driven_snapshot_gets_a_live_suffix() {
let snap = ProgressSnapshot {
done: 32,
total: 128,
phase: "checking out files".into(),
};
assert_eq!(
TerminalSink::format_line(&snap),
"checking out files (32/128, 25%)"
);
}
#[test]
fn preformatted_line_without_a_count_paints_verbatim() {
let snap = ProgressSnapshot {
done: 0,
total: 3,
phase: "[2/3] importing commits... 64/128 inspected (50%)".into(),
};
assert_eq!(
TerminalSink::format_line(&snap),
"[2/3] importing commits... 64/128 inspected (50%)"
);
let counting = ProgressSnapshot {
done: 500,
total: 0,
phase: "scanning".into(),
};
assert_eq!(TerminalSink::format_line(&counting), "scanning");
}
#[test]
fn repaints_on_throttle_boundary_and_edges() {
let state = RenderState {
last_phase: Some("p".into()),
painted: true,
};
for (done, total, want) in [
(0, 100, true),
(64, 100, true),
(100, 100, true),
(63, 100, false),
] {
let snap = ProgressSnapshot {
done,
total,
phase: "p".into(),
};
assert_eq!(
TerminalSink::should_repaint(&state, &snap),
want,
"done={done} total={total}"
);
}
}
}