rebecca 0.3.0

Cross-platform cleanup CLI and Rust library surface for Rebecca cleanup planning, rules, and platform adapters.
Documentation
use std::io::IsTerminal;
use std::path::Path;
use std::time::{Duration, Instant};

use indicatif::{ProgressBar, ProgressStyle};

use crate::output::format_bytes;

pub(crate) const PROGRESS_PATH_MAX_CHARS: usize = 72;

pub(crate) fn stderr_spinner(enabled: bool, initial_message: &'static str) -> Option<ProgressBar> {
    (enabled && std::io::stderr().is_terminal()).then(|| {
        let bar = ProgressBar::new_spinner();
        apply_progress_style(&bar);
        bar.enable_steady_tick(Duration::from_millis(120));
        bar.set_message(initial_message);
        bar
    })
}

pub(crate) fn apply_progress_style(bar: &ProgressBar) {
    if let Ok(style) = ProgressStyle::with_template("{spinner} {msg}") {
        bar.set_style(style.tick_strings(&["-", "\\", "|", "/"]));
    }
}

pub(crate) fn compact_progress_path(path: &Path, max_chars: usize) -> String {
    compact_progress_text(&path.display().to_string(), max_chars)
}

pub(crate) fn compact_progress_text(text: &str, max_chars: usize) -> String {
    let char_count = text.chars().count();
    if char_count <= max_chars {
        return text.to_string();
    }

    if max_chars <= 3 {
        return ".".repeat(max_chars);
    }

    let tail_len = max_chars - 3;
    let tail = text
        .chars()
        .rev()
        .take(tail_len)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect::<String>();
    format!("...{tail}")
}

pub(crate) fn format_file_rate(files_scanned: u64, elapsed: Duration) -> String {
    let seconds = elapsed.as_secs_f64();
    if files_scanned == 0 || seconds <= f64::EPSILON {
        return "0.0 files/s".to_string();
    }

    format!("{:.1} files/s", files_scanned as f64 / seconds)
}

pub(crate) fn format_byte_rate(bytes_scanned: u64, elapsed: Duration) -> String {
    let seconds = elapsed.as_secs_f64();
    if bytes_scanned == 0 || seconds <= f64::EPSILON {
        return "0 B/s".to_string();
    }

    let bytes_per_second = (bytes_scanned as f64 / seconds).round() as u64;
    format!("{}/s", format_bytes(bytes_per_second))
}

#[derive(Debug)]
pub(crate) struct HumanProgressThrottle {
    events_since_refresh: u64,
    last_refresh: Instant,
}

impl HumanProgressThrottle {
    const EVENT_INTERVAL: u64 = 64;
    const TIME_INTERVAL: Duration = Duration::from_millis(250);

    pub(crate) fn new() -> Self {
        Self {
            events_since_refresh: 0,
            last_refresh: Instant::now(),
        }
    }

    pub(crate) fn should_refresh(&mut self) -> bool {
        self.events_since_refresh = self.events_since_refresh.saturating_add(1);
        if self.events_since_refresh < Self::EVENT_INTERVAL
            && self.last_refresh.elapsed() < Self::TIME_INTERVAL
        {
            return false;
        }

        self.events_since_refresh = 0;
        self.last_refresh = Instant::now();
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compact_progress_text_handles_tiny_widths() {
        assert_eq!(compact_progress_text("abcdef", 2), "..");
        assert_eq!(compact_progress_text("abcdef", 4), "...f");
    }

    #[test]
    fn rate_formatters_handle_zero_elapsed_inputs() {
        assert_eq!(format_file_rate(0, Duration::ZERO), "0.0 files/s");
        assert_eq!(format_byte_rate(0, Duration::ZERO), "0 B/s");
    }
}