use std::{fmt::Write, io::Write as _, time::Duration};
use std::io::IsTerminal;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use bytesize::ByteSize;
use indicatif::{HumanDuration, MultiProgress, ProgressBar, ProgressState, ProgressStyle};
use clap::Parser;
use conflate::Merge;
use jiff::SignedDuration;
use log::info;
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, serde_as};
use rustic_core::{Progress, ProgressBars, ProgressType, RusticProgress};
pub fn multi_progress() -> &'static MultiProgress {
static MP: OnceLock<MultiProgress> = OnceLock::new();
MP.get_or_init(|| {
let mp = MultiProgress::new();
mp.set_move_cursor(true);
mp
})
}
mod constants {
use std::time::Duration;
pub(super) const DEFAULT_INTERVAL: Duration = Duration::from_millis(100);
pub(super) const DEFAULT_LOG_INTERVAL: Duration = Duration::from_secs(10);
}
#[serde_as]
#[derive(Default, Debug, Parser, Clone, Copy, Deserialize, Serialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct ProgressOptions {
#[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub no_progress: bool,
#[clap(
long,
global = true,
env = "RUSTIC_JSON_PROGRESS",
conflicts_with = "no_progress"
)]
#[merge(strategy=conflate::bool::overwrite_false)]
pub json_progress: bool,
#[clap(
long,
global = true,
env = "RUSTIC_PROGRESS_INTERVAL",
value_name = "DURATION",
conflicts_with = "no_progress"
)]
#[serde_as(as = "Option<DisplayFromStr>")]
#[merge(strategy=conflate::option::overwrite_none)]
pub progress_interval: Option<SignedDuration>,
}
impl ProgressOptions {
fn interactive_interval(&self) -> Duration {
self.progress_interval
.map_or(constants::DEFAULT_INTERVAL, |i| {
i.try_into().expect("negative durations are not allowed")
})
}
fn log_interval(&self) -> Duration {
self.progress_interval
.map_or(constants::DEFAULT_LOG_INTERVAL, |i| {
i.try_into().expect("negative durations are not allowed")
})
}
fn create_progress(&self, prefix: &str, kind: ProgressType) -> Progress {
if self.no_progress {
return Progress::hidden();
}
let interval = self.log_interval();
if self.json_progress {
return if interval > Duration::ZERO && matches!(kind, ProgressType::Bytes) {
Progress::new(JsonProgress::new(prefix, interval, kind))
} else {
Progress::hidden()
};
}
if std::io::stderr().is_terminal() {
Progress::new(InteractiveProgress::new(
prefix,
kind,
self.interactive_interval(),
))
} else {
if interval > Duration::ZERO {
Progress::new(NonInteractiveProgress::new(prefix, interval, kind))
} else {
Progress::hidden()
}
}
}
}
impl ProgressBars for ProgressOptions {
fn progress(&self, progress_kind: ProgressType, prefix: &str) -> Progress {
self.create_progress(prefix, progress_kind)
}
}
#[derive(Debug, Clone)]
pub struct InteractiveProgress {
bar: ProgressBar,
kind: ProgressType,
}
impl InteractiveProgress {
fn new(prefix: &str, kind: ProgressType, tick_interval: Duration) -> Self {
let style = Self::initial_style(kind);
let bar = multi_progress().add(ProgressBar::new(0).with_style(style));
bar.set_prefix(prefix.to_string());
bar.enable_steady_tick(tick_interval);
Self { bar, kind }
}
#[allow(clippy::literal_string_with_formatting_args)]
fn initial_style(kind: ProgressType) -> ProgressStyle {
let template = match kind {
ProgressType::Spinner => "[{elapsed_precise}] {prefix:30} {spinner}",
ProgressType::Counter => "[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}",
ProgressType::Bytes => {
"[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10} {bytes_per_sec:12}"
}
};
ProgressStyle::default_bar().template(template).unwrap()
}
#[allow(clippy::literal_string_with_formatting_args)]
fn style_with_length(kind: ProgressType) -> ProgressStyle {
match kind {
ProgressType::Spinner => Self::initial_style(kind),
ProgressType::Counter => ProgressStyle::default_bar()
.template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}/{len:10}")
.unwrap(),
ProgressType::Bytes => ProgressStyle::default_bar()
.with_key("my_eta", |s: &ProgressState, w: &mut dyn Write| {
let _ = match (s.pos(), s.len()) {
(pos, Some(len)) if pos != 0 && len > pos => {
let eta_secs = s.elapsed().as_secs() * (len - pos) / pos;
write!(w, "{:#}", HumanDuration(Duration::from_secs(eta_secs)))
}
_ => write!(w, "-"),
};
})
.template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}/{total_bytes:10} {bytes_per_sec:12} (ETA {my_eta})")
.unwrap(),
}
}
}
impl RusticProgress for InteractiveProgress {
fn is_hidden(&self) -> bool {
false
}
fn set_length(&self, len: u64) {
if matches!(self.kind, ProgressType::Bytes | ProgressType::Counter) {
self.bar.set_style(Self::style_with_length(self.kind));
}
self.bar.set_length(len);
}
fn set_title(&self, title: &str) {
self.bar.set_prefix(title.to_string());
}
fn inc(&self, inc: u64) {
self.bar.inc(inc);
}
fn finish(&self) {
self.bar.finish_with_message("done");
}
}
#[derive(Debug)]
struct NonInteractiveState {
prefix: String,
position: u64,
length: Option<u64>,
last_log: Instant,
}
impl NonInteractiveState {
fn progress_text(&self, kind: ProgressType) -> String {
let format_value = |value| match kind {
ProgressType::Bytes => ByteSize(value).to_string(),
ProgressType::Counter | ProgressType::Spinner => value.to_string(),
};
self.length.map_or_else(
|| format_value(self.position),
|len| format!("{} / {}", format_value(self.position), format_value(len)),
)
}
fn should_log(&self, interval: Duration) -> bool {
self.last_log.elapsed() >= interval
}
fn mark_logged(&mut self) {
self.last_log = Instant::now();
}
}
#[derive(Clone, Debug)]
pub struct NonInteractiveProgress {
state: Arc<Mutex<NonInteractiveState>>,
start: Instant,
interval: Duration,
kind: ProgressType,
}
impl NonInteractiveProgress {
fn new(prefix: &str, interval: Duration, kind: ProgressType) -> Self {
let now = Instant::now();
Self {
state: Arc::new(Mutex::new(NonInteractiveState {
prefix: prefix.to_string(),
position: 0,
length: None,
last_log: now,
})),
start: now,
interval,
kind,
}
}
fn format_value(&self, value: u64) -> String {
match self.kind {
ProgressType::Bytes => ByteSize(value).to_string(), ProgressType::Counter | ProgressType::Spinner => value.to_string(),
}
}
fn log_progress(&self, state: &NonInteractiveState) {
info!("{}: {}", state.prefix, state.progress_text(self.kind));
}
}
impl RusticProgress for NonInteractiveProgress {
fn is_hidden(&self) -> bool {
false
}
fn set_length(&self, len: u64) {
if let Ok(mut state) = self.state.lock() {
state.length = Some(len);
}
}
fn set_title(&self, title: &str) {
if let Ok(mut state) = self.state.lock() {
state.prefix = title.to_string();
}
}
fn inc(&self, inc: u64) {
if let Ok(mut state) = self.state.lock() {
state.position += inc;
if state.should_log(self.interval) {
self.log_progress(&state);
state.mark_logged();
}
}
}
fn finish(&self) {
let Ok(state) = self.state.lock() else {
return;
};
info!(
"{}: {} done in {:.2?}",
state.prefix,
self.format_value(state.position),
self.start.elapsed()
);
}
}
#[derive(Clone, Debug)]
pub struct JsonProgress {
state: Arc<Mutex<NonInteractiveState>>,
start: Instant,
interval: Duration,
kind: ProgressType,
}
#[derive(Serialize)]
struct JsonProgressStatus {
message_type: &'static str,
seconds_elapsed: u64,
#[serde(skip_serializing_if = "Option::is_none")]
seconds_remaining: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
percent_done: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
bytes_done: Option<u64>,
}
impl JsonProgress {
fn new(prefix: &str, interval: Duration, kind: ProgressType) -> Self {
let now = Instant::now();
Self {
state: Arc::new(Mutex::new(NonInteractiveState {
prefix: prefix.to_string(),
position: 0,
length: None,
last_log: now,
})),
start: now,
interval,
kind,
}
}
fn log_progress(&self, state: &NonInteractiveState) {
let is_bytes = matches!(self.kind, ProgressType::Bytes);
let elapsed = self.start.elapsed().as_secs();
let percent_done = state
.length
.filter(|len| *len > 0)
.map(|len| (state.position as f64 / len as f64).min(1.0));
let seconds_remaining = match (state.position, state.length) {
(position, Some(len)) if position > 0 && len > position => {
Some(elapsed.saturating_mul(len - position) / position)
}
_ => None,
};
let status = JsonProgressStatus {
message_type: "status",
seconds_elapsed: elapsed,
seconds_remaining,
percent_done,
total_bytes: is_bytes.then_some(state.length).flatten(),
bytes_done: is_bytes.then_some(state.position),
};
let mut stdout = std::io::stdout().lock();
_ = serde_json::to_writer(&mut stdout, &status);
_ = writeln!(stdout);
}
}
impl RusticProgress for JsonProgress {
fn is_hidden(&self) -> bool {
false
}
fn set_length(&self, len: u64) {
if let Ok(mut state) = self.state.lock() {
state.length = Some(len);
self.log_progress(&state);
state.mark_logged();
}
}
fn set_title(&self, title: &str) {
if let Ok(mut state) = self.state.lock() {
state.prefix = title.to_string();
}
}
fn inc(&self, inc: u64) {
if let Ok(mut state) = self.state.lock() {
state.position += inc;
if state.should_log(self.interval) {
self.log_progress(&state);
state.mark_logged();
}
}
}
fn finish(&self) {
let Ok(state) = self.state.lock() else {
return;
};
self.log_progress(&state);
}
}