use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use console::{style, Term};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle, TermLike};
use crate::modules::basic_stats::{BasicStatsCounters, LiveStats};
pub const PROGRESS_ENV: &str = "FASTQC_PROGRESS";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum When {
#[default]
Auto,
Always,
Never,
}
impl When {
fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"always" | "force" | "1" | "yes" | "true" | "on" => When::Always,
"never" | "none" | "0" | "no" | "false" | "off" => When::Never,
_ => When::Auto,
}
}
fn from_env() -> Self {
std::env::var(PROGRESS_ENV)
.map(|v| Self::parse(&v))
.unwrap_or_default()
}
}
const MAX_FILE_BARS: usize = 10;
const MAX_VALUE_WIDTH: usize = 28;
const MIN_VALUE_WIDTH: usize = 16;
const TABLE_REFRESH: Duration = Duration::from_millis(150);
const DRAW_RATE_HZ: u8 = 20;
const SPINNER_TICK: Duration = Duration::from_millis(90);
const DEFAULT_TERM_WIDTH: usize = 100;
const DEFAULT_TERM_HEIGHT: u16 = 24;
const MAX_NAME_WIDTH: usize = 30;
const SCALE: u64 = 1000;
struct LogSink {
multi: MultiProgress,
line_ending: &'static str,
padding: ProgressBar,
}
impl LogSink {
fn print(&self, message: &str) {
self.padding.set_message(" ");
self.multi
.suspend(|| eprint!("{}{}", message, self.line_ending));
}
}
static ACTIVE_LOG: Mutex<Option<Arc<LogSink>>> = Mutex::new(None);
pub fn log_line(message: &str) {
let active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()).clone();
match active {
Some(sink) => sink.print(message),
None => eprintln!("{}", message),
}
}
static RUN: AtomicU64 = AtomicU64::new(1);
pub struct OncePerRun(AtomicU64);
impl Default for OncePerRun {
fn default() -> Self {
Self::new()
}
}
impl OncePerRun {
pub const fn new() -> Self {
OncePerRun(AtomicU64::new(0))
}
pub fn should_say(&self) -> bool {
let run = RUN.load(Ordering::Relaxed);
self.0.load(Ordering::Relaxed) != run && self.0.swap(run, Ordering::Relaxed) != run
}
#[cold]
#[inline(never)]
pub fn log(&self, message: impl FnOnce() -> String) {
if self.should_say() {
log_line(&message());
}
}
}
pub struct ProgressReporter {
mode: Mode,
started: Instant,
}
enum Mode {
Silent,
Plain,
Live(Box<Live>),
}
struct Live {
bars: Bars,
table: Option<Arc<Table>>,
summary: ProgressBar,
trailer: ProgressBar,
log: Arc<LogSink>,
ticker: Mutex<Option<JoinHandle<()>>>,
stop: Arc<AtomicBool>,
}
#[derive(Clone)]
enum Bars {
PerFile(Arc<Vec<FileBar>>),
Aggregate(ProgressBar),
}
struct FileBar {
bar: ProgressBar,
started: AtomicBool,
}
impl Bars {
fn spinners(&self) -> Box<dyn Iterator<Item = &ProgressBar> + '_> {
match self {
Bars::PerFile(bars) => Box::new(
bars.iter()
.filter(|file| file.started.load(Ordering::Relaxed))
.map(|file| &file.bar),
),
Bars::Aggregate(bar) => Box::new(std::iter::once(bar)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModeChoice {
Silent,
Plain,
Live {
bypass_detection: bool,
},
}
fn choose_mode(
quiet: bool,
progress: When,
stderr_is_terminal: bool,
dumb_terminal: bool,
) -> ModeChoice {
if quiet {
return ModeChoice::Silent;
}
let drawable = stderr_is_terminal && !dumb_terminal;
match progress {
When::Always => ModeChoice::Live {
bypass_detection: !drawable,
},
When::Never => ModeChoice::Plain,
When::Auto if drawable => ModeChoice::Live {
bypass_detection: false,
},
When::Auto => ModeChoice::Plain,
}
}
fn current_choice(quiet: bool) -> ModeChoice {
choose_mode(
quiet,
When::from_env(),
Term::stderr().is_term(),
console::is_dumb(),
)
}
pub struct ProgressPlan {
choice: ModeChoice,
started: Instant,
}
impl ProgressPlan {
pub fn new(quiet: bool) -> Self {
if std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
console::set_colors_enabled_stderr(false);
}
let choice = current_choice(quiet);
RUN.fetch_add(1, Ordering::Relaxed);
if let ModeChoice::Live { bypass_detection } = choice {
let line_ending = line_ending(bypass_detection);
eprint!(
"{}{} {}{line_ending}{line_ending}",
paint("FastQC", |s| s.color256(LOGO_BLUE).bold()),
paint("-Rust", |s| s.color256(LOGO_RED).bold()),
paint(&format!("v{}", crate::RUST_VERSION), |s| s.dim()),
);
}
ProgressPlan {
choice,
started: Instant::now(),
}
}
pub fn start(self, names: &[String]) -> ProgressReporter {
let mode = match self.choice {
ModeChoice::Silent => Mode::Silent,
ModeChoice::Plain => Mode::Plain,
ModeChoice::Live { bypass_detection } => {
Mode::Live(Box::new(Live::new(names, bypass_detection)))
}
};
ProgressReporter {
mode,
started: self.started,
}
}
}
fn line_ending(bypass_detection: bool) -> &'static str {
if bypass_detection {
"\r\n"
} else {
"\n"
}
}
impl ProgressReporter {
pub fn hidden() -> Self {
ProgressReporter {
mode: Mode::Silent,
started: Instant::now(),
}
}
pub fn file(&self, index: usize) -> FileProgress<'_> {
FileProgress {
reporter: self,
index,
}
}
pub fn error(&self, message: &str) {
log_line(&paint_error(message));
}
pub fn finish(&self, analysed: usize, failed: bool) {
let summary = format!(
"Analysed {} {} in {}",
analysed,
if analysed == 1 { "file" } else { "files" },
clock_duration(self.started.elapsed()),
);
let complete = paint("Complete.", |s| {
if failed { s.red() } else { s.green() }.bold()
});
match &self.mode {
Mode::Silent => {}
Mode::Plain => eprintln!("{} {}", complete, summary),
Mode::Live(live) => {
live.summary
.set_message(format!("{} {}", complete, paint(&summary, |s| s.dim())));
live.finish(analysed);
}
}
}
}
#[derive(Clone, Copy)]
pub struct FileProgress<'a> {
reporter: &'a ProgressReporter,
index: usize,
}
impl FileProgress<'_> {
pub fn live_stats(&self) -> Option<Arc<LiveStats>> {
match &self.reporter.mode {
Mode::Live(live) => live
.table
.as_ref()
.and_then(|t| t.columns.get(self.index))
.map(|c| Arc::clone(&c.live)),
_ => None,
}
}
pub fn start(&self, name: &str) {
match &self.reporter.mode {
Mode::Silent => {}
Mode::Plain => eprintln!("Started analysis of {}", paint(name, |s| s.bold())),
Mode::Live(live) => live.start(self.index),
}
}
pub fn update(&self, reads: u64, percent: impl FnOnce() -> f64) {
if let Mode::Live(live) = &self.reporter.mode {
live.progress(self.index, reads, percent);
}
}
pub fn stage(&self, stage: &str) {
if let Mode::Live(live) = &self.reporter.mode {
live.stage(self.index, stage);
}
}
pub fn finish(&self, name: &str, reads: u64) {
match &self.reporter.mode {
Mode::Silent => {}
Mode::Plain => eprintln!("Analysis complete for {}", paint(name, |s| s.bold())),
Mode::Live(live) => live.finish_file(self.index, reads),
}
}
pub fn fail(&self) {
if let Mode::Live(live) = &self.reporter.mode {
live.fail_file(self.index);
}
}
}
impl Live {
fn new(names: &[String], bypass_detection: bool) -> Self {
let multi = if bypass_detection {
MultiProgress::with_draw_target(ProgressDrawTarget::term_like_with_hz(
Box::new(ForcedTerm::new()),
DRAW_RATE_HZ,
))
} else {
MultiProgress::new()
};
let log = Arc::new(LogSink {
multi: multi.clone(),
line_ending: line_ending(bypass_detection),
padding: static_line(&multi),
});
*ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&log));
let label_width = names
.iter()
.map(|n| console::measure_text_width(n))
.max()
.unwrap_or(0)
.min(MAX_NAME_WIDTH)
.max("FastQ files".len());
let bars = if names.len() > MAX_FILE_BARS {
let bar = multi.add(ProgressBar::new(names.len() as u64));
bar.set_style(aggregate_style());
bar.set_prefix(pad_cell(&paint("FastQ files", |s| s.bold()), label_width));
Bars::Aggregate(bar)
} else {
let running = running_style();
let bars = names
.iter()
.map(|name| {
let bar = multi.add(ProgressBar::new(SCALE));
bar.set_style(running.clone());
bar.set_prefix(pad_cell(&paint(name, |s| s.bold()), label_width));
bar.set_message("waiting");
bar.tick();
FileBar {
bar,
started: AtomicBool::new(false),
}
})
.collect();
Bars::PerFile(Arc::new(bars))
};
let table = (!names.is_empty()).then(|| Arc::new(Table::new(&multi, names)));
let summary = static_line(&multi);
let trailer = static_line(&multi);
trailer.set_message(" ");
let live = Live {
bars,
table,
summary,
trailer,
log,
ticker: Mutex::new(None),
stop: Arc::new(AtomicBool::new(false)),
};
live.start_ticker();
live
}
fn start_ticker(&self) {
let table = self.table.as_ref().map(Arc::clone);
let bars = self.bars.clone();
let stop = Arc::clone(&self.stop);
let handle = std::thread::Builder::new()
.name("fastqc-progress".into())
.spawn(move || {
let mut due = Instant::now();
while !stop.load(Ordering::Relaxed) {
for bar in bars.spinners() {
if !bar.is_finished() {
bar.tick();
}
}
let now = Instant::now();
if now >= due {
due = now + TABLE_REFRESH;
if let Some(table) = &table {
table.refresh();
}
}
std::thread::sleep(SPINNER_TICK);
}
if let Some(table) = &table {
table.refresh();
}
});
if let Ok(handle) = handle {
*self.ticker.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
}
}
fn bar(&self, index: usize) -> Option<&ProgressBar> {
match &self.bars {
Bars::PerFile(bars) => bars.get(index).map(|file| &file.bar),
Bars::Aggregate(_) => None,
}
}
fn start(&self, index: usize) {
let Bars::PerFile(bars) = &self.bars else {
return;
};
let Some(file) = bars.get(index) else {
return;
};
file.started.store(true, Ordering::Relaxed);
file.bar.reset_elapsed();
file.bar.set_message("reading");
}
fn progress(&self, index: usize, reads: u64, percent: impl FnOnce() -> f64) {
let Some(bar) = self.bar(index) else {
return;
};
let position = (percent().clamp(0.0, 100.0) / 100.0 * SCALE as f64) as u64;
if bar.position() != position {
bar.set_position(position);
}
let label = format!("{} reads", human_count(reads));
if bar.message() != label {
bar.set_message(label);
}
}
fn stage(&self, index: usize, stage: &str) {
if let Some(bar) = self.bar(index) {
bar.set_position(SCALE);
bar.set_message(stage.to_string());
}
}
fn set_file_state(&self, index: usize, state: FileState) {
if let Some(column) = self.table.as_ref().and_then(|t| t.columns.get(index)) {
column.state.store(state as u8, Ordering::Relaxed);
}
}
fn finish_file(&self, index: usize, reads: u64) {
self.set_file_state(index, FileState::Analysed);
match &self.bars {
Bars::PerFile(bars) => {
if let Some(file) = bars.get(index) {
file.bar.set_style(done_style());
file.bar.set_position(SCALE);
file.bar
.set_message(format!("{} reads", human_count(reads)));
file.bar.finish();
}
}
Bars::Aggregate(bar) => bar.inc(1),
}
}
fn fail_file(&self, index: usize) {
self.set_file_state(index, FileState::Failed);
match &self.bars {
Bars::PerFile(bars) => {
if let Some(file) = bars.get(index) {
file.bar.set_style(failed_style());
file.bar.set_message("failed");
file.bar.abandon();
}
}
Bars::Aggregate(bar) => bar.inc(1),
}
}
fn finish(&self, analysed: usize) {
if let Bars::Aggregate(bar) = &self.bars {
bar.set_style(if bar.length() == Some(analysed as u64) {
aggregate_done_style()
} else {
aggregate_failed_style()
});
bar.finish();
}
self.shut_down();
if let Some(table) = &self.table {
table.finish();
}
self.log.padding.finish();
self.summary.finish();
self.trailer.finish();
}
fn shut_down(&self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.ticker.lock().unwrap_or_else(|e| e.into_inner()).take() {
let _ = handle.join();
}
let mut active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner());
if active
.as_ref()
.is_some_and(|sink| Arc::ptr_eq(sink, &self.log))
{
*active = None;
}
}
}
impl Drop for Live {
fn drop(&mut self) {
self.shut_down();
}
}
fn static_line(multi: &MultiProgress) -> ProgressBar {
let line = multi.add(ProgressBar::new(0));
line.set_style(ProgressStyle::with_template("{msg}").expect("static template"));
line.tick();
line
}
#[derive(Debug)]
struct ForcedTerm {
inner: Term,
width: u16,
height: u16,
}
fn measured_term_width() -> usize {
Term::stderr()
.size_checked()
.map(|(_, cols)| cols)
.or_else(|| env_dimension("COLUMNS"))
.unwrap_or(DEFAULT_TERM_WIDTH as u16) as usize
}
impl ForcedTerm {
fn new() -> Self {
let inner = Term::buffered_stderr();
let measured = inner.size_checked();
ForcedTerm {
width: measured_term_width() as u16,
height: measured
.map(|(rows, _)| rows)
.or_else(|| env_dimension("LINES"))
.unwrap_or(DEFAULT_TERM_HEIGHT),
inner,
}
}
fn escape(&self, n: usize, op: char) -> std::io::Result<()> {
if n == 0 {
return Ok(());
}
self.inner.write_str(&format!("\x1b[{n}{op}"))
}
}
fn env_dimension(name: &str) -> Option<u16> {
std::env::var(name)
.ok()?
.parse::<u16>()
.ok()
.filter(|n| *n > 0)
}
impl TermLike for ForcedTerm {
fn width(&self) -> u16 {
self.width
}
fn height(&self) -> u16 {
self.height
}
fn move_cursor_up(&self, n: usize) -> std::io::Result<()> {
self.escape(n, 'A')
}
fn move_cursor_down(&self, n: usize) -> std::io::Result<()> {
self.escape(n, 'B')
}
fn move_cursor_right(&self, n: usize) -> std::io::Result<()> {
self.escape(n, 'C')
}
fn move_cursor_left(&self, n: usize) -> std::io::Result<()> {
self.escape(n, 'D')
}
fn write_line(&self, s: &str) -> std::io::Result<()> {
self.inner.write_line(s)
}
fn write_str(&self, s: &str) -> std::io::Result<()> {
self.inner.write_str(s)
}
fn clear_line(&self) -> std::io::Result<()> {
self.inner.write_str("\r\x1b[2K")
}
fn flush(&self) -> std::io::Result<()> {
self.inner.flush()
}
}
struct Table {
line: ProgressBar,
columns: Vec<Column>,
names: Vec<String>,
state: Mutex<TableState>,
}
struct TableState {
width: usize,
geometry: Option<Geometry>,
last: String,
}
struct Geometry {
value_width: usize,
top: String,
divider: String,
bottom: String,
pipe: String,
heading_label: String,
row_labels: Vec<String>,
headings: Vec<[String; 3]>,
}
struct Column {
live: Arc<LiveStats>,
state: AtomicU8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileState {
Running = 0,
Analysed = 1,
Failed = 2,
}
impl Geometry {
fn new(names: &[String], term_width: usize) -> Option<Self> {
let (label_width, value_width) = layout(names.len(), term_width)?;
let rule = |left: char, mid: char, right: char| {
let mut s = String::from(" ");
s.push(left);
s.push_str(&"─".repeat(label_width + 2));
for _ in names {
s.push(mid);
s.push_str(&"─".repeat(value_width + 2));
}
s.push(right);
paint(&s, |st| st.dim())
};
Some(Geometry {
value_width,
top: rule('┌', '┬', '┐'),
divider: rule('├', '┼', '┤'),
bottom: rule('└', '┴', '┘'),
pipe: paint("│", |s| s.dim()),
heading_label: pad_cell(&paint("Measure", |s| s.dim()), label_width),
row_labels: BasicStatsCounters::MEASURES
.iter()
.map(|m| pad_cell(m, label_width))
.collect(),
headings: names
.iter()
.map(|name| {
[
pad_cell(&paint(name, |s| s.cyan().bold()), value_width),
pad_cell(&paint(name, |s| s.green().bold()), value_width),
pad_cell(&paint(name, |s| s.red().bold()), value_width),
]
})
.collect(),
})
}
fn row<'a>(&self, label_cell: &str, cells: impl Iterator<Item = &'a str>) -> String {
let mut s = String::from(" ");
s.push_str(&self.pipe);
s.push(' ');
s.push_str(label_cell);
s.push(' ');
for cell in cells {
s.push_str(&self.pipe);
s.push(' ');
s.push_str(cell);
s.push(' ');
}
s.push_str(&self.pipe);
s
}
}
impl Table {
fn new(multi: &MultiProgress, names: &[String]) -> Self {
let columns = names
.iter()
.map(|_| Column {
live: Arc::new(LiveStats::new()),
state: AtomicU8::new(FileState::Running as u8),
})
.collect::<Vec<_>>();
let width = measured_term_width();
let table = Table {
line: static_line(multi),
columns,
names: names.to_vec(),
state: Mutex::new(TableState {
width,
geometry: Geometry::new(names, width),
last: String::new(),
}),
};
table.refresh();
table
}
fn finish(&self) {
self.line.finish();
}
fn refresh(&self) {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let width = measured_term_width();
if width != state.width {
state.width = width;
state.geometry = Geometry::new(&self.names, width);
}
let Some(geometry) = &state.geometry else {
if !state.last.is_empty() {
state.last = String::new();
self.line.set_message("");
}
return;
};
let mut out: Vec<String> = Vec::with_capacity(geometry.row_labels.len() + 5);
out.push(" ".to_string());
out.push(geometry.top.clone());
out.push(geometry.row(
&geometry.heading_label,
self.columns.iter().enumerate().map(|(index, column)| {
let file_state = column.state.load(Ordering::Relaxed) as usize;
let headings = &geometry.headings[index];
headings.get(file_state).unwrap_or(&headings[0]).as_str()
}),
));
out.push(geometry.divider.clone());
let values: Vec<Vec<String>> = self
.columns
.iter()
.map(|column| {
let snapshot = column.live.snapshot();
column.live.request();
match snapshot {
None => vec!["-".to_string(); geometry.row_labels.len()],
Some(counters) => counters.rows().into_iter().map(|(_, v)| v).collect(),
}
})
.collect();
for (index, label) in geometry.row_labels.iter().enumerate() {
let cells: Vec<String> = values
.iter()
.map(|column| pad_cell(&paint(&column[index], |s| s.white()), geometry.value_width))
.collect();
out.push(geometry.row(label, cells.iter().map(String::as_str)));
}
out.push(geometry.bottom.clone());
let rendered = out.join("\n");
if state.last == rendered {
return;
}
self.line.set_message(rendered.clone());
state.last = rendered;
}
}
fn table_overhead(columns: usize) -> usize {
2 + (columns + 2) + 2 * (columns + 1)
}
fn layout(columns: usize, term_width: usize) -> Option<(usize, usize)> {
if columns == 0 {
return None;
}
let label_width = BasicStatsCounters::MEASURES
.iter()
.map(|m| console::measure_text_width(m))
.max()
.unwrap_or(8);
let available = term_width
.saturating_sub(table_overhead(columns))
.checked_sub(label_width)?;
let value_width = (available / columns).min(MAX_VALUE_WIDTH);
(value_width >= MIN_VALUE_WIDTH).then_some((label_width, value_width))
}
fn pad_cell(text: &str, width: usize) -> String {
console::pad_str(text, width, console::Alignment::Left, Some("…")).into_owned()
}
fn paint_error(text: &str) -> String {
paint(text, |s| s.red())
}
fn paint(
text: &str,
apply: impl FnOnce(console::StyledObject<&str>) -> console::StyledObject<&str>,
) -> String {
apply(style(text).for_stderr()).to_string()
}
fn elapsed_key(
) -> impl Fn(&indicatif::ProgressState, &mut dyn std::fmt::Write) + Clone + Send + Sync + 'static {
move |state: &indicatif::ProgressState, w: &mut dyn std::fmt::Write| {
let _ = write!(
w,
"{}",
paint(&short_duration(state.elapsed()), |s| s.dim())
);
}
}
pub const LOGO_BLUE: u8 = 69;
pub const LOGO_RED: u8 = 167;
const PROGRESS_CHARS: &str = "━╸━";
const TICK_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
fn bar_style(marker: &str, color: &str, middle: &str) -> ProgressStyle {
ProgressStyle::with_template(&format!(
" {{prefix}} {marker} {{wide_bar:.{color}/238}} {middle} {{elapsed:>5}}"
))
.expect("static template")
.progress_chars(PROGRESS_CHARS)
.tick_chars(TICK_CHARS)
.with_key("elapsed", elapsed_key())
}
const FILE_FIELDS: &str = "{percent:>3}% {msg:<12}";
const AGGREGATE_FIELDS: &str = "{pos}/{len} files";
fn running_style() -> ProgressStyle {
bar_style("{spinner:.cyan}", "cyan", FILE_FIELDS)
}
fn done_style() -> ProgressStyle {
bar_style(&paint("✔", |s| s.green().bold()), "green", FILE_FIELDS)
}
fn failed_style() -> ProgressStyle {
bar_style(
&paint("✘", |s| s.red().bold()),
"red",
&format!("{{percent:>3}}% {}", paint("{msg:<12}", |s| s.red())),
)
}
fn aggregate_style() -> ProgressStyle {
bar_style("{spinner:.cyan}", "cyan", AGGREGATE_FIELDS)
}
fn aggregate_done_style() -> ProgressStyle {
bar_style(&paint("✔", |s| s.green().bold()), "green", AGGREGATE_FIELDS)
}
fn aggregate_failed_style() -> ProgressStyle {
bar_style(&paint("✘", |s| s.red().bold()), "red", AGGREGATE_FIELDS)
}
fn clock_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs < 3600 {
format!("{:02}:{:02}", secs / 60, secs % 60)
} else {
format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
}
}
fn short_duration(d: Duration) -> String {
let tenths = (d.as_secs_f64() * 10.0).round() as u64;
if tenths < 600 {
return format!("{}.{}s", tenths / 10, tenths % 10);
}
let secs = d.as_secs().max(60);
if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
fn human_count(n: u64) -> String {
if n >= 999_950_000 {
format!("{:.1}B", n as f64 / 1e9)
} else if n >= 999_950 {
format!("{:.1}M", n as f64 / 1e6)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1e3)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_human_count() {
assert_eq!(human_count(0), "0");
assert_eq!(human_count(999), "999");
assert_eq!(human_count(1_500), "1.5k");
assert_eq!(human_count(3_000_000), "3.0M");
assert_eq!(human_count(2_500_000_000), "2.5B");
assert_eq!(human_count(999_999), "1.0M");
assert_eq!(human_count(999_999_999), "1.0B");
assert_eq!(human_count(999_949), "999.9k");
assert_eq!(human_count(999_949_999), "999.9M");
}
#[test]
fn test_short_duration() {
assert_eq!(short_duration(Duration::from_millis(4200)), "4.2s");
assert_eq!(short_duration(Duration::from_millis(59_960)), "1m00s");
assert_eq!(short_duration(Duration::from_secs(72)), "1m12s");
assert_eq!(short_duration(Duration::from_secs(3840)), "1h04m");
}
#[test]
fn test_when_parse() {
for on in [
"always", "ALWAYS", " always ", "force", "1", "yes", "true", "on",
] {
assert_eq!(When::parse(on), When::Always, "{on:?}");
}
for off in ["never", "Never", "none", "0", "no", "false", "off"] {
assert_eq!(When::parse(off), When::Never, "{off:?}");
}
for other in ["", "auto", "maybe", "yes please"] {
assert_eq!(When::parse(other), When::Auto, "{other:?}");
}
}
#[test]
fn test_choose_mode_auto() {
let auto = When::Auto;
assert_eq!(
choose_mode(false, auto, true, false),
ModeChoice::Live {
bypass_detection: false
}
);
assert_eq!(choose_mode(false, auto, false, false), ModeChoice::Plain);
assert_eq!(choose_mode(false, auto, true, true), ModeChoice::Plain);
assert_eq!(choose_mode(false, auto, false, true), ModeChoice::Plain);
}
#[test]
fn test_choose_mode_forced() {
for (is_terminal, dumb, bypass_detection) in [
(true, false, false),
(true, true, true),
(false, false, true),
(false, true, true),
] {
assert_eq!(
choose_mode(false, When::Always, is_terminal, dumb),
ModeChoice::Live { bypass_detection },
"always must draw the display (tty={is_terminal}, dumb={dumb})"
);
assert_eq!(
choose_mode(false, When::Never, is_terminal, dumb),
ModeChoice::Plain,
"never must not draw the display (tty={is_terminal}, dumb={dumb})"
);
}
}
#[test]
fn test_quiet_beats_forced_progress() {
for progress in [When::Auto, When::Always, When::Never] {
for &is_terminal in &[true, false] {
assert_eq!(
choose_mode(true, progress, is_terminal, false),
ModeChoice::Silent,
"--quiet must win over {progress:?}"
);
}
}
}
#[test]
fn test_log_line_without_a_display() {
assert!(ACTIVE_LOG
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none());
log_line("no display active, so this goes straight to stderr");
}
#[test]
fn test_a_shown_table_renders_inside_the_terminal() {
let natural_label = BasicStatsCounters::MEASURES
.iter()
.map(|m| console::measure_text_width(m))
.max()
.unwrap();
for term_width in [40usize, 60, 72, 80, 100, 120, 160, 200, 400] {
for columns in 1..=12 {
let Some((label_width, value_width)) = layout(columns, term_width) else {
continue;
};
let total = table_overhead(columns) + label_width + value_width * columns;
assert!(
total <= term_width,
"shown table of {columns} columns overflows {term_width} cols (needs {total})"
);
assert!(
value_width >= MIN_VALUE_WIDTH,
"value column below the readable minimum at {term_width} cols"
);
assert_eq!(
label_width, natural_label,
"measure column was squeezed at {term_width} cols"
);
}
}
}
#[test]
fn test_table_visibility_follows_terminal_width() {
let fits = |columns, width| layout(columns, width).is_some();
assert!(!fits(0, 200));
assert!(fits(1, 80));
assert!(fits(3, 80));
assert!(!fits(4, 80));
for columns in 1..=10 {
let threshold = (1..600)
.find(|w| fits(columns, *w))
.expect("some width is wide enough");
assert!(
(threshold..600).all(|w| fits(columns, w)),
"{columns} columns: fits at {threshold} but not at every wider width"
);
if columns > 1 {
let narrower = (1..600).find(|w| fits(columns - 1, *w)).unwrap();
assert!(narrower < threshold);
}
}
assert!(!fits(1, 24));
}
#[test]
fn test_pad_cell_fits_the_column_around_ansi() {
let styled = style("abc").red().force_styling(true).to_string();
let padded = pad_cell(&styled, 6);
assert_eq!(console::measure_text_width(&padded), 6);
assert!(padded.starts_with(&styled));
let long = style("abcdefghij").red().force_styling(true).to_string();
let cut = pad_cell(&long, 6);
assert_eq!(console::measure_text_width(&cut), 6);
assert!(cut.contains('…'), "not truncated with an ellipsis: {cut:?}");
assert!(cut.ends_with("\u{1b}[0m"), "lost its reset: {cut:?}");
}
#[test]
fn test_log_padding_appears_with_the_first_message() {
let multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
let sink = LogSink {
multi: multi.clone(),
line_ending: "\n",
padding: static_line(&multi),
};
assert_eq!(
sink.padding.message(),
"",
"padded before anything was said"
);
sink.print("something happened");
assert_eq!(sink.padding.message(), " ", "no padding after a message");
sink.print("and again");
assert_eq!(sink.padding.message(), " ");
}
#[test]
fn test_geometry_follows_the_width_it_is_built_for() {
let names = vec![
"sample_1.fastq.gz".to_string(),
"sample_2.fastq.gz".to_string(),
];
let narrow = Geometry::new(&names, 50);
assert!(narrow.is_none(), "two columns cannot be readable at 50");
let wide = Geometry::new(&names, 200).expect("two columns fit at 200");
let wider = Geometry::new(&names, 400).expect("two columns fit at 400");
assert_eq!(wide.headings.len(), names.len());
for (geometry, term_width) in [(&wide, 200usize), (&wider, 400)] {
let heading = geometry.row(
&geometry.heading_label,
geometry.headings.iter().map(|h| h[0].as_str()),
);
for line in [&geometry.top, &geometry.divider, &geometry.bottom, &heading] {
assert_eq!(
console::measure_text_width(line),
console::measure_text_width(&geometry.top),
"table lines disagree on width at {term_width} cols"
);
assert!(
console::measure_text_width(line) <= term_width,
"table overflows {term_width} cols"
);
}
}
assert_eq!(wider.value_width, MAX_VALUE_WIDTH);
}
#[test]
fn test_hidden_reporter_is_inert() {
let reporter = ProgressReporter::hidden();
let file = reporter.file(0);
file.start("a.fastq");
file.update(1000, || 50.0);
file.stage("writing report");
file.finish("a.fastq", 2000);
file.fail();
assert!(file.live_stats().is_none());
reporter.finish(1, false);
}
}