use std::{
io::{IsTerminal, Write},
time::{Duration, Instant},
};
use crate::traversal::SearchStats;
const SPINNER_INTERVAL: Duration = Duration::from_millis(100);
const LOG_INTERVAL: Duration = Duration::from_secs(5);
const SPINNER: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Style {
Spinner,
Log,
}
pub(crate) enum Progress {
Disabled,
Enabled {
style: Style,
interval: Duration,
start: Instant,
last_tick: Instant,
frame: usize,
},
}
impl Progress {
pub(crate) fn new(enabled: bool) -> Self {
if !enabled {
return Self::Disabled;
}
let (style, interval) = if std::io::stderr().is_terminal() {
(Style::Spinner, SPINNER_INTERVAL)
} else {
(Style::Log, LOG_INTERVAL)
};
let now = Instant::now();
Self::Enabled {
style,
interval,
start: now,
last_tick: now - interval,
frame: 0,
}
}
pub(crate) fn tick(&mut self, stats: &SearchStats) {
let Self::Enabled {
style,
interval,
start,
last_tick,
frame,
} = self
else {
return;
};
let now = Instant::now();
if now.duration_since(*last_tick) < *interval {
return;
}
*last_tick = now;
let body = render_body(now.duration_since(*start), stats);
let mut err = std::io::stderr().lock();
match style {
Style::Spinner => {
let _ = write!(err, "\r{} {body}", SPINNER[*frame % SPINNER.len()]);
}
Style::Log => {
let _ = writeln!(err, "rxgraph search: {body}");
}
}
let _ = err.flush();
*frame += 1;
}
pub(crate) fn finish(&self, stats: &SearchStats) {
let Self::Enabled { style, start, .. } = self else {
return;
};
let body = render_body(start.elapsed(), stats);
let mut err = std::io::stderr().lock();
match style {
Style::Spinner => {
let _ = write!(err, "\r\x1b[2K");
let _ = writeln!(err, "✓ {body}");
}
Style::Log => {
let _ = writeln!(err, "rxgraph search done: {body}");
}
}
let _ = err.flush();
}
}
fn render_body(elapsed: Duration, stats: &SearchStats) -> String {
format!(
"{:.1}s edges {} accepted {} paths {}",
elapsed.as_secs_f64(),
stats.evaluated_edges,
stats.accepted_edges,
stats.stopped_paths,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_progress_is_noop() {
let mut p = Progress::new(false);
p.tick(&SearchStats::default());
p.finish(&SearchStats::default());
assert!(matches!(p, Progress::Disabled));
}
#[test]
fn render_body_includes_counters() {
let stats = SearchStats {
evaluated_edges: 12,
accepted_edges: 5,
stopped_paths: 2,
..SearchStats::default()
};
let body = render_body(Duration::from_millis(1500), &stats);
assert!(body.contains("edges 12"));
assert!(body.contains("accepted 5"));
assert!(body.contains("paths 2"));
assert!(body.contains("1.5s"));
}
}