rars-cli 0.5.0

Command-line interface for the rars RAR archive toolkit.
use crate::cli::ProgressMode;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use rars::{WriteOperation, WriteProgress, WriteProgressEvent};
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;

const LOG_INTERVAL: Duration = Duration::from_secs(10);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RenderMode {
    Terminal,
    Milestones,
    Periodic,
    Hidden,
}

#[derive(Default)]
struct PlainState {
    active: bool,
    stop: bool,
    message: String,
    last_percent: u8,
}

pub(crate) struct CliProgress {
    mode: RenderMode,
    bar: ProgressBar,
    plain: Arc<(Mutex<PlainState>, Condvar)>,
    heartbeat: Mutex<Option<JoinHandle<()>>>,
    determinate: AtomicBool,
}

impl CliProgress {
    pub(crate) fn new(mode: ProgressMode) -> Self {
        let terminal = std::io::stderr().is_terminal();
        let mode = match (mode, terminal) {
            (ProgressMode::Never, _) => RenderMode::Hidden,
            (_, true) => RenderMode::Terminal,
            (ProgressMode::Always, false) => RenderMode::Periodic,
            (ProgressMode::Auto, false) => RenderMode::Milestones,
        };
        let bar = if mode == RenderMode::Terminal {
            ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr_with_hz(12))
        } else {
            ProgressBar::hidden()
        };
        let plain = Arc::new((Mutex::new(PlainState::default()), Condvar::new()));
        let heartbeat = if mode == RenderMode::Periodic {
            let state = Arc::clone(&plain);
            Some(std::thread::spawn(move || heartbeat_loop(state)))
        } else {
            None
        };
        Self {
            mode,
            bar,
            plain,
            heartbeat: Mutex::new(heartbeat),
            determinate: AtomicBool::new(false),
        }
    }

    pub(crate) fn spinner(&self, message: impl Into<String>) {
        let message = message.into();
        self.set_plain_state(true, &message);
        self.determinate.store(false, Ordering::Relaxed);
        self.reset_plain_percent();
        match self.mode {
            RenderMode::Terminal => {
                // A previous phase may have used `finish_and_clear`, which leaves an
                // indicatif bar in a terminal state until it is fully reset.
                self.bar.reset();
                self.bar.set_length(0);
                self.bar.set_position(0);
                self.bar.reset_elapsed();
                self.bar.set_style(
                    ProgressStyle::with_template("{spinner} [{elapsed_precise}] {wide_msg}")
                        .expect("valid progress spinner template")
                        // indicatif reserves the last string for the finished state.
                        .tick_strings(&["-", "\\", "|", "/", "-"]),
                );
                self.bar.set_message(message);
                self.bar.enable_steady_tick(Duration::from_millis(120));
            }
            RenderMode::Milestones | RenderMode::Periodic => {
                eprintln!("progress: {message}");
            }
            RenderMode::Hidden => {}
        }
    }

    pub(crate) fn bar(&self, message: impl Into<String>, total: u64) {
        let message = message.into();
        self.set_plain_state(true, &message);
        self.determinate.store(true, Ordering::Relaxed);
        self.reset_plain_percent();
        if self.mode == RenderMode::Terminal {
            self.bar.reset();
            self.bar.disable_steady_tick();
            self.bar.set_length(total);
            self.bar.set_position(0);
            self.bar.reset_elapsed();
            self.bar.set_style(
                ProgressStyle::with_template(
                    "[{elapsed_precise}] {bar:32.cyan/blue} {bytes}/{total_bytes} {bytes_per_sec} ETA {eta} {wide_msg}",
                )
                .expect("valid progress bar template")
                .progress_chars("=>-"),
            );
            self.bar.set_message(message);
        } else if matches!(self.mode, RenderMode::Milestones | RenderMode::Periodic) {
            eprintln!("progress: {message}");
        }
    }

    fn work_bar(&self, message: String, total: u64) {
        self.set_plain_state(true, &message);
        self.determinate.store(true, Ordering::Relaxed);
        self.reset_plain_percent();
        self.bar.reset();
        self.bar.disable_steady_tick();
        self.bar.set_length(total);
        self.bar.set_position(0);
        self.bar.reset_elapsed();
        self.bar.set_style(
            ProgressStyle::with_template(
                "[{elapsed_precise}] {bar:32.cyan/blue} {percent:>3}% ETA {eta} {wide_msg}",
            )
            .expect("valid compression progress bar template")
            .progress_chars("=>-"),
        );
        self.bar.set_message(message);
    }

    pub(crate) fn advance(&self, bytes: u64) {
        if self.mode == RenderMode::Terminal {
            self.bar.inc(bytes);
        }
    }

    pub(crate) fn set_message(&self, message: impl Into<String>) {
        let message = message.into();
        self.set_plain_state(true, &message);
        if self.mode == RenderMode::Terminal {
            self.bar.set_message(message);
        }
    }

    pub(crate) fn finish(&self, message: impl Into<String>) {
        let message = message.into();
        self.set_plain_state(false, &message);
        match self.mode {
            RenderMode::Terminal => {
                self.bar.disable_steady_tick();
                self.bar.finish_and_clear();
            }
            RenderMode::Milestones | RenderMode::Periodic => {
                eprintln!("progress: {message}");
            }
            RenderMode::Hidden => {}
        }
    }

    fn set_plain_state(&self, active: bool, message: &str) {
        let (lock, wake) = &*self.plain;
        let mut state = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
        state.active = active;
        state.message.clear();
        state.message.push_str(message);
        wake.notify_all();
    }

    fn reset_plain_percent(&self) {
        let (lock, _) = &*self.plain;
        lock.lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .last_percent = 0;
    }

    fn report_plain_percent(&self, completed: u64, total: u64) {
        if total == 0 || !matches!(self.mode, RenderMode::Milestones | RenderMode::Periodic) {
            return;
        }
        let percent = completed
            .saturating_mul(100)
            .checked_div(total)
            .unwrap_or(100)
            .min(100) as u8;
        let step = if self.mode == RenderMode::Periodic {
            10
        } else {
            25
        };
        let threshold = percent / step * step;
        let (lock, _) = &*self.plain;
        let mut state = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
        if threshold > state.last_percent {
            state.last_percent = threshold;
            eprintln!("progress: {threshold}% {}", state.message);
        }
    }
}

impl WriteProgress for CliProgress {
    fn report(&self, event: WriteProgressEvent<'_>) {
        match event {
            WriteProgressEvent::OperationStarted {
                operation,
                total_bytes,
                total_entries,
                pass,
            } => {
                let label = operation_label(operation, pass);
                let _ = (total_bytes, total_entries);
                self.spinner(label);
            }
            WriteProgressEvent::EntryStarted { name, .. } => {
                self.set_message(format!("Compressing {}", display_bytes(name)));
            }
            WriteProgressEvent::EntryFinished { input_bytes, .. } => self.advance(input_bytes),
            WriteProgressEvent::Advanced {
                operation,
                completed_bytes,
                total_bytes,
                pass,
            } => {
                if self.mode == RenderMode::Terminal {
                    if !self.determinate.swap(true, Ordering::Relaxed) {
                        let current = self.bar.message();
                        let message = if current.is_empty() {
                            operation_label(operation, pass)
                        } else {
                            current.to_string()
                        };
                        self.work_bar(message, total_bytes);
                    }
                    self.bar.set_length(total_bytes);
                    self.bar.set_position(completed_bytes);
                } else {
                    self.report_plain_percent(completed_bytes, total_bytes);
                }
            }
            WriteProgressEvent::OperationFinished {
                operation, pass, ..
            } => {
                self.finish(format!("{} complete", operation_label(operation, pass)));
            }
            _ => {}
        }
    }
}

impl Drop for CliProgress {
    fn drop(&mut self) {
        self.bar.finish_and_clear();
        let (lock, wake) = &*self.plain;
        let mut state = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
        state.stop = true;
        wake.notify_all();
        drop(state);
        if let Some(handle) = self
            .heartbeat
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .take()
        {
            let _ = handle.join();
        }
    }
}

fn heartbeat_loop(shared: Arc<(Mutex<PlainState>, Condvar)>) {
    let (lock, wake) = &*shared;
    let mut state = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    loop {
        let (next, timeout) = wake
            .wait_timeout(state, LOG_INTERVAL)
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        state = next;
        if state.stop {
            break;
        }
        if timeout.timed_out() && state.active {
            eprintln!("progress: still working: {}", state.message);
        }
    }
}

fn operation_label(operation: WriteOperation, pass: usize) -> String {
    match operation {
        WriteOperation::Compression => "Compressing archive".to_string(),
        WriteOperation::Recovery if pass > 1 => format!("Building recovery record (pass {pass})"),
        WriteOperation::Recovery => "Building recovery record".to_string(),
        _ => "Preparing archive".to_string(),
    }
}

fn display_bytes(bytes: &[u8]) -> String {
    let mut out = String::new();
    for ch in String::from_utf8_lossy(bytes).chars() {
        if ch.is_control() {
            out.extend(ch.escape_default());
        } else {
            out.push(ch);
        }
    }
    out
}