use eyre::Result;
use std::io::{IsTerminal, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
#[cfg(debug_assertions)]
use std::sync::atomic::AtomicU64;
#[cfg(debug_assertions)]
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) const PROGRESS_RENDER_DELAY: Duration = Duration::from_secs(5);
const PROGRESS_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[cfg(test)]
pub(super) const DEBUG_SLOW_DISK_MIN_DELAY: Duration = Duration::from_millis(250);
#[cfg(test)]
pub(super) const DEBUG_SLOW_DISK_MAX_DELAY: Duration = Duration::from_millis(1_250);
#[cfg(debug_assertions)]
const DEBUG_SLOW_DISK_MIN_MILLIS: u64 = 250;
#[cfg(debug_assertions)]
const DEBUG_SLOW_DISK_MAX_MILLIS: u64 = 1_250;
#[cfg(debug_assertions)]
const SPLITMIX64_INCREMENT: u64 = 0x9e37_79b9_7f4a_7c15;
#[cfg(debug_assertions)]
const DEFAULT_SLOW_DISK_SEED: u64 = 0xa5a5_5a5a_c3c3_3c3c;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) struct ScanBehavior {
show_progress: bool,
#[cfg(debug_assertions)]
simulate_slow_disk: bool,
}
impl ScanBehavior {
#[cfg(debug_assertions)]
pub(super) const fn cli(show_progress: bool, simulate_slow_disk: bool) -> Self {
Self {
show_progress,
simulate_slow_disk,
}
}
#[cfg(not(debug_assertions))]
pub(super) const fn cli(show_progress: bool) -> Self {
Self { show_progress }
}
}
pub(super) trait ScanBatchRunner {
type Observer: ScanObserver;
fn run_batch<T, F>(&self, total_files: usize, scan: F) -> Result<T>
where
F: FnOnce(&Self::Observer) -> Result<T>;
}
pub(super) trait ScanObserver: Clone + Send + Sync {
fn before_file_open(&self) {}
fn on_file_complete(&self) {}
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct NoopScanObserver;
#[cfg(test)]
impl ScanObserver for NoopScanObserver {}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct NoopScanBatchRunner;
#[cfg(test)]
impl ScanBatchRunner for NoopScanBatchRunner {
type Observer = NoopScanObserver;
fn run_batch<T, F>(&self, _total_files: usize, scan: F) -> Result<T>
where
F: FnOnce(&Self::Observer) -> Result<T>,
{
scan(&NoopScanObserver)
}
}
#[derive(Clone)]
pub(super) struct BatchScanObserver {
progress: Option<Arc<ProgressState>>,
#[cfg(debug_assertions)]
slow_disk: Option<Arc<SlowDiskSimulator>>,
}
impl BatchScanObserver {
fn new(
total_files: usize,
behavior: ScanBehavior,
stderr_is_terminal: bool,
) -> (Self, Option<ProgressGuard>) {
let progress = progress_enabled(behavior.show_progress, total_files, stderr_is_terminal)
.then(|| Arc::new(ProgressState::new(total_files)));
let progress_guard = progress
.as_ref()
.map(|state| ProgressGuard::spawn(Arc::clone(state)));
(
Self {
progress,
#[cfg(debug_assertions)]
slow_disk: behavior
.simulate_slow_disk
.then(|| Arc::new(SlowDiskSimulator::new())),
},
progress_guard,
)
}
}
impl ScanObserver for BatchScanObserver {
fn before_file_open(&self) {
#[cfg(debug_assertions)]
if let Some(simulator) = &self.slow_disk {
simulator.sleep_once();
}
}
fn on_file_complete(&self) {
if let Some(progress) = &self.progress {
progress.completed_files.fetch_add(1, Ordering::Relaxed);
}
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct CliScanBatchRunner {
behavior: ScanBehavior,
}
impl CliScanBatchRunner {
pub(super) const fn new(behavior: ScanBehavior) -> Self {
Self { behavior }
}
}
impl ScanBatchRunner for CliScanBatchRunner {
type Observer = BatchScanObserver;
fn run_batch<T, F>(&self, total_files: usize, scan: F) -> Result<T>
where
F: FnOnce(&Self::Observer) -> Result<T>,
{
let context = BatchScanContext::new(total_files, self.behavior);
let observer = context.observer();
scan(&observer)
}
}
struct BatchScanContext {
observer: BatchScanObserver,
_progress_guard: Option<ProgressGuard>,
}
impl BatchScanContext {
pub(super) fn new(total_files: usize, behavior: ScanBehavior) -> Self {
Self::new_with_terminal(total_files, behavior, std::io::stderr().is_terminal())
}
fn new_with_terminal(
total_files: usize,
behavior: ScanBehavior,
stderr_is_terminal: bool,
) -> Self {
let (observer, progress_guard) =
BatchScanObserver::new(total_files, behavior, stderr_is_terminal);
Self {
observer,
_progress_guard: progress_guard,
}
}
pub(super) fn observer(&self) -> BatchScanObserver {
self.observer.clone()
}
}
struct ProgressState {
total_files: usize,
completed_files: AtomicUsize,
stop_requested: AtomicBool,
}
impl ProgressState {
fn new(total_files: usize) -> Self {
Self {
total_files,
completed_files: AtomicUsize::new(0),
stop_requested: AtomicBool::new(false),
}
}
}
struct ProgressGuard {
state: Arc<ProgressState>,
handle: Option<JoinHandle<()>>,
}
impl ProgressGuard {
fn spawn(state: Arc<ProgressState>) -> Self {
let worker_state = Arc::clone(&state);
let handle = thread::Builder::new()
.name("scan-progress".to_string())
.spawn(move || run_progress_worker(&worker_state))
.ok();
Self { state, handle }
}
}
impl Drop for ProgressGuard {
fn drop(&mut self) {
self.state.stop_requested.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
fn run_progress_worker(state: &ProgressState) {
let started_at = Instant::now();
let mut displayed_line = None;
loop {
if state.stop_requested.load(Ordering::Relaxed) {
break;
}
if let Some(line) = progress_frame(
started_at.elapsed(),
state.completed_files.load(Ordering::Relaxed),
state.total_files,
) {
let should_render = displayed_line.as_ref() != Some(&line);
if should_render {
write_transient_line(&line);
displayed_line = Some(line);
}
}
thread::sleep(PROGRESS_POLL_INTERVAL);
}
if let Some(line) = displayed_line {
write_transient_line(&clear_progress_line(&line));
}
}
fn progress_enabled(show_progress: bool, total_files: usize, stderr_is_terminal: bool) -> bool {
show_progress && total_files > 0 && stderr_is_terminal
}
fn progress_frame(elapsed: Duration, completed: usize, total: usize) -> Option<String> {
(elapsed >= PROGRESS_RENDER_DELAY).then(|| format_progress_line(completed, total))
}
fn write_transient_line(line: &str) {
let mut stderr = std::io::stderr().lock();
let _ = stderr.write_all(line.as_bytes());
let _ = stderr.flush();
}
pub(super) fn format_progress_line(completed: usize, total: usize) -> String {
format!("\rparsing {completed}/{total}...")
}
pub(super) fn clear_progress_line(line: &str) -> String {
let visible_width = line.strip_prefix('\r').map_or(line.len(), str::len);
format!("\r{}\r", " ".repeat(visible_width))
}
#[cfg(debug_assertions)]
struct SlowDiskSimulator {
state: AtomicU64,
}
#[cfg(debug_assertions)]
impl SlowDiskSimulator {
fn new() -> Self {
Self {
state: AtomicU64::new(initial_slow_disk_seed()),
}
}
fn sleep_once(&self) {
thread::sleep(sample_slow_disk_delay(&self.state));
}
}
#[cfg(debug_assertions)]
fn initial_slow_disk_seed() -> u64 {
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0_u128, |duration| duration.as_nanos());
let now_bytes = now_nanos.to_le_bytes();
let mut low = [0_u8; 8];
low.copy_from_slice(&now_bytes[..8]);
let mut high = [0_u8; 8];
high.copy_from_slice(&now_bytes[8..]);
let seed = u64::from_le_bytes(low)
^ u64::from_le_bytes(high)
^ u64::from(std::process::id())
^ DEFAULT_SLOW_DISK_SEED;
if seed == 0 {
DEFAULT_SLOW_DISK_SEED
} else {
seed
}
}
#[cfg(debug_assertions)]
pub(super) fn sample_slow_disk_delay(state: &AtomicU64) -> Duration {
let span = DEBUG_SLOW_DISK_MAX_MILLIS - DEBUG_SLOW_DISK_MIN_MILLIS;
let offset = splitmix64_next(state) % (span + 1);
Duration::from_millis(DEBUG_SLOW_DISK_MIN_MILLIS + offset)
}
#[cfg(debug_assertions)]
fn splitmix64_next(state: &AtomicU64) -> u64 {
let mut value = state.fetch_add(SPLITMIX64_INCREMENT, Ordering::Relaxed);
value = value.wrapping_add(SPLITMIX64_INCREMENT);
value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
#[cfg(test)]
mod tests {
use super::{
BatchScanContext, PROGRESS_RENDER_DELAY, ScanBehavior, clear_progress_line,
format_progress_line, progress_frame,
};
#[cfg(debug_assertions)]
use super::{DEBUG_SLOW_DISK_MAX_DELAY, DEBUG_SLOW_DISK_MIN_DELAY, sample_slow_disk_delay};
#[cfg(debug_assertions)]
use std::sync::atomic::AtomicU64;
use std::time::Duration;
#[test]
fn progress_render_delay_stays_at_five_seconds() {
assert_eq!(PROGRESS_RENDER_DELAY, Duration::from_secs(5));
}
#[test]
fn progress_frame_stays_hidden_before_threshold() {
assert!(progress_frame(Duration::from_secs(4), 0, 12).is_none());
}
#[test]
fn format_progress_line_uses_requested_shape() {
assert_eq!(format_progress_line(12, 34), "\rparsing 12/34...");
}
#[test]
fn clear_progress_line_erases_previous_content_without_ansi() {
assert_eq!(
clear_progress_line("\rparsing 12/34..."),
"\r \r"
);
}
#[test]
fn batch_scan_context_disables_progress_when_stderr_is_not_terminal() {
#[cfg(debug_assertions)]
let behavior = ScanBehavior::cli(true, false);
#[cfg(not(debug_assertions))]
let behavior = ScanBehavior::cli(true);
let context = BatchScanContext::new_with_terminal(5, behavior, false);
assert!(context.observer.progress.is_none());
}
#[cfg(debug_assertions)]
#[test]
fn sampled_slow_disk_delay_stays_within_requested_range() {
let state = AtomicU64::new(0x1234_5678_9abc_def0);
let delay = sample_slow_disk_delay(&state);
assert!(delay >= DEBUG_SLOW_DISK_MIN_DELAY);
assert!(delay <= DEBUG_SLOW_DISK_MAX_DELAY);
}
#[cfg(debug_assertions)]
#[test]
fn sampled_slow_disk_delay_varies_between_draws() {
let state = AtomicU64::new(0xfeed_face_cafe_beef);
let first = sample_slow_disk_delay(&state);
let second = sample_slow_disk_delay(&state);
assert_ne!(first, second);
}
}