Skip to main content

fastqc_rust/
progress.rs

1//! Live terminal progress reporting, drawn in place:
2//!
3//! ```text
4//! FastQC-Rust v1.1.0
5//!
6//! Failed to process notes.txt: ID line didn't start with '@' at line 1
7//!   sample_1.fastq.gz  ⠹ ━━━━━━━━━━━━━━━━━━━━╸━━━━━━━  72%  2.1M reads     4s
8//!   sample_2.fastq.gz  ✔ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%  3.0M reads     6s
9//!
10//!   ┌───────────────────────────────────┬────────────────┬────────────────┐
11//!   │ Measure                           │ sample_1.fast… │ sample_2.fast… │
12//!   ├───────────────────────────────────┼────────────────┼────────────────┤
13//!   │ File type                         │ Conventional … │ Conventional … │
14//!   │ ...                               │ ...            │ ...            │
15//!   └───────────────────────────────────┴────────────────┴────────────────┘
16//! Complete. Analysed 2 files in 00:06
17//! ```
18//!
19//! The bars, the table and the closing summary are a redrawn region pinned to
20//! the bottom of the terminal. The name and version, and any warning or error,
21//! are ordinary log lines that scroll up above it.
22//!
23//! The display adapts to the size of the run:
24//!
25//! * **1-10 files** get a progress bar each, in the order given on the command
26//!   line, showing that file's own progress.
27//! * **More than 10 files** collapse to a single bar counting completed files,
28//!   because a screenful of bars is worse than no bars at all.
29//! * **A live statistics table** is drawn underneath whenever the terminal is
30//!   wide enough for every column to be readable — so a wide terminal gets it
31//!   for more files, and a narrow one does without. Its columns are the files
32//!   and its rows the Basic Statistics measures from the top of the HTML
33//!   report. Cells start as `-` and fill in as the analysis runs; the final
34//!   values are exactly those in the report, because both are rendered from the
35//!   same counters (see
36//!   [`crate::modules::basic_stats::BasicStatsCounters::rows`]).
37//!
38//! # Animation and colour
39//!
40//! These are two independent switches, each auto-detected and each overridable
41//! through the environment — there are no command-line flags for them.
42//! `--quiet` beats both and says nothing but warnings and errors.
43//!
44//! **Animation** (`FASTQC_PROGRESS=auto|always|never`) — the default `auto`
45//! draws the display only for an interactive stderr. When stderr is a pipe, a
46//! log file or a workflow engine's capture, or when `TERM` says the terminal
47//! cannot handle a redrawn region (`dumb`, or unset on Unix), a redrawn region
48//! would be noise or outright corruption, so the reporter degrades to one plain
49//! line per file at start and finish. `always` draws it regardless — for
50//! recording a demo, or feeding a consumer that re-renders the stream — by
51//! handing indicatif the terminal directly instead of its self-hiding stderr
52//! draw target, sizing itself from `COLUMNS`/`LINES` since there is no terminal
53//! to measure. `never` always takes the plain path.
54//!
55//! **Colour** follows [`console::colors_enabled_stderr`], which implements the
56//! usual conventions: the [clicolors spec](https://bixense.com/clicolors/)
57//! `CLICOLOR=0` disables colour and `CLICOLOR_FORCE=1` forces it on even for a
58//! pipe, and `TERM=dumb` disables it. A non-empty `NO_COLOR` disables it over
59//! all of those, which console does not do on its own.
60//! indicatif's template styling reads the same function, so one signal covers
61//! this module's styling and the bars alike.
62//!
63//! Because the two are independent: colour off still draws the bars, just
64//! without escape codes; and the plain fallback still colours its lines when
65//! colour is forced on, which is what a CI log viewer wants — it renders escape
66//! sequences happily while not being a terminal.
67//!
68//! # Log lines
69//!
70//! Anything printed while the display is up has to go through [`log_line`], or
71//! [`ProgressReporter::error`]. Writing to stderr directly would land in the
72//! middle of the display and be erased by the next frame. A warning raised from
73//! an inner analysis loop pairs [`log_line`] with an [`OncePerRun`] latch, so
74//! the run says it once instead of once per base.
75//!
76//! Those lines are written *above* the redrawn region and scroll up as ordinary
77//! terminal output, which is what rich, tqdm, indicatif, cargo and Nextflow all
78//! do. The log is the permanent record and has to be able to grow without
79//! bound, and the terminal's scrollback is the only place unbounded output can
80//! go. The version banner is printed by [`ProgressPlan::new`] before the run
81//! does anything else, so everything the run has to say appears beneath it in
82//! the order it happened.
83//!
84//! The exception is the closing `Complete. Analysed N files in mm:ss`, which is
85//! the last line of the redrawn region so that it always lands below the bars
86//! and the table. `--quiet` suppresses it along with everything else.
87
88use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
89use std::sync::{Arc, Mutex};
90use std::thread::JoinHandle;
91use std::time::{Duration, Instant};
92
93use console::{style, Term};
94use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle, TermLike};
95
96use crate::modules::basic_stats::{BasicStatsCounters, LiveStats};
97
98/// Environment variable selecting the progress display. `FASTQC_`-prefixed so
99/// that it cannot collide with anything in the environment it is read from.
100pub const PROGRESS_ENV: &str = "FASTQC_PROGRESS";
101
102/// A tri-state switch for behaviour that is normally auto-detected.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum When {
105    /// Decide from the environment (default).
106    #[default]
107    Auto,
108    /// Force on, whatever the environment looks like.
109    Always,
110    /// Force off.
111    Never,
112}
113
114impl When {
115    /// Parse a `FASTQC_PROGRESS`-style value. Unrecognised values fall back to
116    /// `Auto` rather than failing a run over a display preference.
117    fn parse(value: &str) -> Self {
118        match value.trim().to_ascii_lowercase().as_str() {
119            "always" | "force" | "1" | "yes" | "true" | "on" => When::Always,
120            "never" | "none" | "0" | "no" | "false" | "off" => When::Never,
121            _ => When::Auto,
122        }
123    }
124
125    fn from_env() -> Self {
126        std::env::var(PROGRESS_ENV)
127            .map(|v| Self::parse(&v))
128            .unwrap_or_default()
129    }
130}
131
132/// Above this many files, per-file bars are replaced by a single bar counting
133/// completed files.
134const MAX_FILE_BARS: usize = 10;
135
136/// Widest a value column grows before the extra room is left as margin.
137const MAX_VALUE_WIDTH: usize = 28;
138
139/// Narrowest a statistics-table value column may be and still be worth reading.
140/// Whether the table is shown at all is decided by whether every column can
141/// have at least this much room (see [`layout`]), so a wide terminal shows
142/// the table for more files and a narrow one drops it sooner.
143const MIN_VALUE_WIDTH: usize = 16;
144
145/// How often the live statistics table is re-rendered.
146const TABLE_REFRESH: Duration = Duration::from_millis(150);
147
148/// Redraw rate for a forced display, matching indicatif's own default for its
149/// stderr draw target.
150const DRAW_RATE_HZ: u8 = 20;
151
152/// Spinner animation period for the running bars.
153const SPINNER_TICK: Duration = Duration::from_millis(90);
154
155/// Fallback terminal size when it cannot be measured and the environment does
156/// not say (`FASTQC_PROGRESS=always` over a pipe, for instance).
157const DEFAULT_TERM_WIDTH: usize = 100;
158const DEFAULT_TERM_HEIGHT: u16 = 24;
159
160/// Longest a file name may be before it is truncated in a bar label.
161const MAX_NAME_WIDTH: usize = 30;
162
163/// Progress is tracked in permille rather than percent so the bars move
164/// smoothly rather than in 1% steps.
165const SCALE: u64 = 1000;
166
167/// Where log lines go while a display is on screen.
168///
169/// Lines are written above the redrawn region and scroll up as ordinary
170/// terminal output, which is the convention for this kind of display (rich,
171/// tqdm, indicatif, cargo, Nextflow all do the same). The log is the permanent
172/// record and has to be able to grow without bound; the terminal's scrollback
173/// is the only place unbounded output can go, and it is below the display that
174/// there is no room.
175struct LogSink {
176    multi: MultiProgress,
177    /// `\r\n` when the display has been forced onto something that is not a
178    /// terminal. A tty driver rewrites `\n` as `\r\n` on the way out (ONLCR);
179    /// nothing does that for a pipe, so a bare newline would leave the cursor
180    /// parked in this line's column and the next frame would start there.
181    line_ending: &'static str,
182    /// First line of the redrawn region, blank once anything has been logged so
183    /// the bars are not flush against the messages above them. It renders as
184    /// nothing while empty, which is what keeps it out of the way on the usual
185    /// run that logs nothing at all.
186    padding: ProgressBar,
187}
188
189impl LogSink {
190    fn print(&self, message: &str) {
191        self.padding.set_message(" ");
192        // Erase the region, write the line as ordinary scrollback, and redraw
193        // the display beneath it, all under indicatif's draw lock so no other
194        // thread's frame can land between the erase and the line.
195        self.multi
196            .suspend(|| eprint!("{}{}", message, self.line_ending));
197    }
198}
199
200/// The log sink of the display currently drawing to stderr, if there is one.
201///
202/// Code deep in the analysis (a module noticing bad data, a reader hitting an
203/// odd record) has no handle on the reporter, but its warnings must not be
204/// written straight to stderr while a redrawn region is on screen — they would
205/// land in the middle of the bars and be overwritten by the next frame.
206static ACTIVE_LOG: Mutex<Option<Arc<LogSink>>> = Mutex::new(None);
207
208/// Emit a line above the progress display, wherever it is called from. Falls
209/// back to plain stderr when no display is active.
210pub fn log_line(message: &str) {
211    let active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()).clone();
212    match active {
213        Some(sink) => sink.print(message),
214        None => eprintln!("{}", message),
215    }
216}
217
218/// Which run is in progress. Bumped by [`ProgressPlan::new`], and compared
219/// against by [`OncePerRun`] to know whether it has already spoken.
220static RUN: AtomicU64 = AtomicU64::new(1);
221
222/// A latch for a warning raised from an innermost analysis loop, where the same
223/// message can be produced millions of times — once per bad base, once per
224/// unreadable read.
225///
226/// Declared as a `static` beside the code that raises the warning, so asking
227/// whether to speak is one relaxed atomic: no formatting, no allocation and no
228/// lock on the overwhelmingly common repeat. That matters because these sit in
229/// the hot loop — building the message unconditionally and then discarding it
230/// cost ~26% of the analysis on a file whose every base tripped the warning.
231///
232/// The latch is per *run*, not per process: it resets when the next
233/// [`ProgressPlan`] is created, so an embedder driving the library twice hears
234/// the warning both times. Within a run it fires once however many files are
235/// being analysed in parallel, which is the point — the warning is about the
236/// data, and one copy of it is the useful amount.
237pub struct OncePerRun(AtomicU64);
238
239impl Default for OncePerRun {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl OncePerRun {
246    pub const fn new() -> Self {
247        OncePerRun(AtomicU64::new(0))
248    }
249
250    /// True for exactly one caller per run. Racing threads all swap in the
251    /// current run, and only the one that displaced an older value speaks.
252    /// The plain load first keeps repeats off the cache line's write path.
253    pub fn should_say(&self) -> bool {
254        let run = RUN.load(Ordering::Relaxed);
255        self.0.load(Ordering::Relaxed) != run && self.0.swap(run, Ordering::Relaxed) != run
256    }
257
258    /// [`log_line`] the warning `message` builds, the first time this run.
259    /// Out of line so the hot loop it is raised from stays tight.
260    #[cold]
261    #[inline(never)]
262    pub fn log(&self, message: impl FnOnce() -> String) {
263        if self.should_say() {
264            log_line(&message());
265        }
266    }
267}
268
269/// The terminal progress display for a whole run.
270///
271/// Cheap to share across the rayon workers: every method is `&self` and
272/// no-ops when the display is disabled.
273pub struct ProgressReporter {
274    mode: Mode,
275    /// When the run started, for the closing summary.
276    started: Instant,
277}
278
279enum Mode {
280    /// `--quiet`: say nothing at all.
281    Silent,
282    /// No redrawn display: one plain line per file at start and finish. Still
283    /// colour-aware, because a CI log viewer renders escape sequences happily
284    /// even though it is not a terminal.
285    Plain,
286    /// A terminal: live bars, and optionally a live statistics table.
287    Live(Box<Live>),
288}
289
290struct Live {
291    bars: Bars,
292    table: Option<Arc<Table>>,
293    /// The closing summary, drawn as the last line of the region so it always
294    /// lands below the bars and the table. Empty until the run finishes.
295    summary: ProgressBar,
296    /// Blank line kept at the bottom of the redrawn region.
297    trailer: ProgressBar,
298    /// Where log lines go: above the display, as ordinary scrollback.
299    log: Arc<LogSink>,
300    ticker: Mutex<Option<JoinHandle<()>>>,
301    stop: Arc<AtomicBool>,
302}
303
304/// Cheap to clone: the ticker thread holds its own handle.
305#[derive(Clone)]
306enum Bars {
307    /// One bar per file, indexed by file group.
308    PerFile(Arc<Vec<FileBar>>),
309    /// A single bar counting completed files.
310    Aggregate(ProgressBar),
311}
312
313/// One file's bar, plus whether its analysis has actually begun.
314///
315/// The flag matters because indicatif starts a bar's clock when the bar is
316/// *created*, and all the bars are created together before any file is opened.
317/// A run with more files than parallel slots would otherwise show a queued file
318/// counting up the time it spent waiting, and then report that as how long it
319/// took. So the clock is reset when the file starts, and until then the bar is
320/// left alone entirely — not even ticked, so its spinner does not animate as
321/// though something were happening.
322struct FileBar {
323    bar: ProgressBar,
324    started: AtomicBool,
325}
326
327impl Bars {
328    /// The bars whose spinner should be advancing: the ones whose file is
329    /// actually being worked on. A file still queued behind another has nothing
330    /// happening, and a spinning spinner would say otherwise.
331    fn spinners(&self) -> Box<dyn Iterator<Item = &ProgressBar> + '_> {
332        match self {
333            Bars::PerFile(bars) => Box::new(
334                bars.iter()
335                    .filter(|file| file.started.load(Ordering::Relaxed))
336                    .map(|file| &file.bar),
337            ),
338            Bars::Aggregate(bar) => Box::new(std::iter::once(bar)),
339        }
340    }
341}
342
343/// Which of the three display modes a run should use.
344///
345/// Split out from [`ProgressReporter::new`] so the decision can be tested
346/// without a terminal or environment fiddling.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum ModeChoice {
349    Silent,
350    Plain,
351    /// `bypass_detection` when the display is going somewhere indicatif would
352    /// refuse to draw to, which is the one thing `Live::new` needs to know: it
353    /// then has to drive the terminal itself rather than through indicatif's
354    /// self-hiding stderr target. That is a fact about the output, not about
355    /// what was asked for — `FASTQC_PROGRESS=always` in a real terminal is an
356    /// ordinary live display.
357    Live {
358        bypass_detection: bool,
359    },
360}
361
362/// Decide how to report progress.
363///
364/// `--quiet` wins over everything: it is the stronger statement, so
365/// `--quiet FASTQC_PROGRESS=always` is still silent. Otherwise
366/// `FASTQC_PROGRESS` decides if it was given explicitly, and only `auto`
367/// consults the environment.
368///
369/// Auto-detection needs a terminal the display can redraw in place: stderr must
370/// be a tty *and* `TERM` must describe a terminal that can do more than accept
371/// plain text. `dumb` (and, on Unix, an unset `TERM`) fails that second test —
372/// indicatif hides its bars in exactly that case, so without the check a run
373/// would print a banner and then go completely silent.
374fn choose_mode(
375    quiet: bool,
376    progress: When,
377    stderr_is_terminal: bool,
378    dumb_terminal: bool,
379) -> ModeChoice {
380    if quiet {
381        return ModeChoice::Silent;
382    }
383    // The same condition indicatif's stderr draw target hides itself on, so
384    // when it holds there is nothing to bypass and `always` costs nothing.
385    let drawable = stderr_is_terminal && !dumb_terminal;
386    match progress {
387        When::Always => ModeChoice::Live {
388            bypass_detection: !drawable,
389        },
390        When::Never => ModeChoice::Plain,
391        When::Auto if drawable => ModeChoice::Live {
392            bypass_detection: false,
393        },
394        When::Auto => ModeChoice::Plain,
395    }
396}
397
398/// The mode this process would use, from `--quiet` and the environment.
399///
400/// Called once, by [`ProgressPlan::new`], which stores the answer so both
401/// phases of startup act on the same decision. Split from [`choose_mode`] so
402/// that the rule itself can be tested without a terminal or the environment.
403fn current_choice(quiet: bool) -> ModeChoice {
404    choose_mode(
405        quiet,
406        When::from_env(),
407        // console's own tty probe, not `std::io::IsTerminal`: indicatif decides
408        // whether to hide its bars with `console::Term::is_term`, and the two
409        // disagree on an MSYS pty, where console recognises a terminal that
410        // `IsTerminal` does not.
411        Term::stderr().is_term(),
412        console::is_dumb(),
413    )
414}
415
416/// A decided-but-not-yet-drawn display, and the first half of starting a run.
417///
418/// The display cannot be built until the input files have been validated and
419/// grouped, because it needs one bar per group — but the banner has to be
420/// printed *before* that work, or the messages validation emits land above it
421/// and the "everything the run says appears beneath the banner" property is
422/// quietly false. So the decision and the banner happen here, at the very top
423/// of the run, and [`start`](Self::start) turns the plan into a reporter once
424/// the names are known.
425pub struct ProgressPlan {
426    choice: ModeChoice,
427    started: Instant,
428}
429
430impl ProgressPlan {
431    /// Decide how this run will report, and announce it. Call this first:
432    /// the clock it starts is the one the closing summary reports.
433    pub fn new(quiet: bool) -> Self {
434        // console lets CLICOLOR_FORCE override NO_COLOR; the NO_COLOR spec
435        // says it wins over everything.
436        if std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
437            console::set_colors_enabled_stderr(false);
438        }
439        let choice = current_choice(quiet);
440        // "Once per run" for [`OncePerRun`] means once per plan.
441        RUN.fetch_add(1, Ordering::Relaxed);
442        if let ModeChoice::Live { bypass_detection } = choice {
443            // Coloured after the logo: the name in its blue, the `-Rust`
444            // suffix in its red, the version dim. Written straight to stderr
445            // because nothing has been drawn yet — there is no region to clear
446            // and no padding to add.
447            let line_ending = line_ending(bypass_detection);
448            eprint!(
449                "{}{} {}{line_ending}{line_ending}",
450                paint("FastQC", |s| s.color256(LOGO_BLUE).bold()),
451                paint("-Rust", |s| s.color256(LOGO_RED).bold()),
452                paint(&format!("v{}", crate::RUST_VERSION), |s| s.dim()),
453            );
454        }
455        ProgressPlan {
456            choice,
457            started: Instant::now(),
458        }
459    }
460
461    /// Draw the display for `names` (the file group display names, in
462    /// command-line order).
463    pub fn start(self, names: &[String]) -> ProgressReporter {
464        let mode = match self.choice {
465            ModeChoice::Silent => Mode::Silent,
466            ModeChoice::Plain => Mode::Plain,
467            ModeChoice::Live { bypass_detection } => {
468                Mode::Live(Box::new(Live::new(names, bypass_detection)))
469            }
470        };
471        ProgressReporter {
472            mode,
473            started: self.started,
474        }
475    }
476}
477
478/// A tty driver rewrites `\n` as `\r\n` on the way out (ONLCR); nothing does
479/// that for a pipe, so a bare newline would leave the cursor parked in this
480/// line's column and the next frame would start there.
481fn line_ending(bypass_detection: bool) -> &'static str {
482    if bypass_detection {
483        "\r\n"
484    } else {
485        "\n"
486    }
487}
488
489impl ProgressReporter {
490    /// A reporter that displays nothing. Used by tests and by callers of the
491    /// library API that drive the analysis themselves.
492    pub fn hidden() -> Self {
493        ProgressReporter {
494            mode: Mode::Silent,
495            started: Instant::now(),
496        }
497    }
498
499    /// A handle scoped to one file group, for the code that actually runs the
500    /// analysis.
501    pub fn file(&self, index: usize) -> FileProgress<'_> {
502        FileProgress {
503            reporter: self,
504            index,
505        }
506    }
507
508    /// Print an error line above the display. Shown even under `--quiet`.
509    ///
510    /// Routed through [`log_line`] rather than matched on the mode: the two
511    /// would say the same thing, since a log sink is registered exactly while
512    /// a display is up.
513    pub fn error(&self, message: &str) {
514        log_line(&paint_error(message));
515    }
516
517    /// Tear the display down once every file is done, leaving the final state
518    /// on screen, and report what the run got through.
519    ///
520    /// `analysed` is the number of file groups that completed successfully;
521    /// anything that failed has already been reported as an error line.
522    /// `failed` turns the closing "Complete." red.
523    pub fn finish(&self, analysed: usize, failed: bool) {
524        let summary = format!(
525            "Analysed {} {} in {}",
526            analysed,
527            if analysed == 1 { "file" } else { "files" },
528            clock_duration(self.started.elapsed()),
529        );
530        let complete = paint("Complete.", |s| {
531            if failed { s.red() } else { s.green() }.bold()
532        });
533        match &self.mode {
534            // --quiet stays quiet: the run said nothing, so it ends saying
535            // nothing.
536            Mode::Silent => {}
537            Mode::Plain => eprintln!("{} {}", complete, summary),
538            Mode::Live(live) => {
539                // The last line of the redrawn region, so it lands below the
540                // bars and the table rather than scrolling past above them.
541                live.summary
542                    .set_message(format!("{} {}", complete, paint(&summary, |s| s.dim())));
543                live.finish(analysed);
544            }
545        }
546    }
547}
548
549/// A progress handle for a single file group.
550#[derive(Clone, Copy)]
551pub struct FileProgress<'a> {
552    reporter: &'a ProgressReporter,
553    index: usize,
554}
555
556impl FileProgress<'_> {
557    /// The live statistics sink for this file, if the table is being shown.
558    /// Attached to the file's BasicStats module so it can publish partial
559    /// results as it works.
560    pub fn live_stats(&self) -> Option<Arc<LiveStats>> {
561        match &self.reporter.mode {
562            Mode::Live(live) => live
563                .table
564                .as_ref()
565                .and_then(|t| t.columns.get(self.index))
566                .map(|c| Arc::clone(&c.live)),
567            _ => None,
568        }
569    }
570
571    /// Announce that analysis of this file has begun.
572    pub fn start(&self, name: &str) {
573        match &self.reporter.mode {
574            Mode::Silent => {}
575            Mode::Plain => eprintln!("Started analysis of {}", paint(name, |s| s.bold())),
576            Mode::Live(live) => live.start(self.index),
577        }
578    }
579
580    /// Report how far through the file the reader is. `reads` is the number of
581    /// records handed to the modules so far.
582    ///
583    /// `percent` is a closure rather than a value because
584    /// `SequenceFile::percent_complete` costs a seek on the input file, and
585    /// there is often no bar for it to move — under `--quiet`, without a
586    /// terminal, or when many files share a single completion-counting bar.
587    /// Leaving that decision here keeps the reader from having to ask.
588    pub fn update(&self, reads: u64, percent: impl FnOnce() -> f64) {
589        if let Mode::Live(live) = &self.reporter.mode {
590            live.progress(self.index, reads, percent);
591        }
592    }
593
594    /// Note that the file has been read and the run has moved on to another
595    /// phase (rendering charts, writing the report).
596    pub fn stage(&self, stage: &str) {
597        if let Mode::Live(live) = &self.reporter.mode {
598            live.stage(self.index, stage);
599        }
600    }
601
602    /// Mark the file finished.
603    pub fn finish(&self, name: &str, reads: u64) {
604        match &self.reporter.mode {
605            Mode::Silent => {}
606            Mode::Plain => eprintln!("Analysis complete for {}", paint(name, |s| s.bold())),
607            Mode::Live(live) => live.finish_file(self.index, reads),
608        }
609    }
610
611    /// Mark the file failed.
612    pub fn fail(&self) {
613        if let Mode::Live(live) = &self.reporter.mode {
614            live.fail_file(self.index);
615        }
616    }
617}
618
619impl Live {
620    /// `bypass_detection` means stderr is not something indicatif will draw a
621    /// redrawn region to, and the display has been forced on anyway.
622    fn new(names: &[String], bypass_detection: bool) -> Self {
623        // The default stderr draw target hides itself when stderr is not an
624        // interactive terminal. `FASTQC_PROGRESS=always` asks for the display
625        // anyway, so bypass that check by handing indicatif the terminal
626        // directly: `term_like` performs no detection of its own. It also
627        // applies no rate limiting unless one is given, which would redraw the
628        // whole display on every single position update, so pass the same
629        // refresh rate `ProgressDrawTarget::stderr` uses.
630        let multi = if bypass_detection {
631            MultiProgress::with_draw_target(ProgressDrawTarget::term_like_with_hz(
632                Box::new(ForcedTerm::new()),
633                DRAW_RATE_HZ,
634            ))
635        } else {
636            MultiProgress::new()
637        };
638
639        let log = Arc::new(LogSink {
640            multi: multi.clone(),
641            line_ending: line_ending(bypass_detection),
642            // First line of the region, so the blank it grows lands between the
643            // messages and the bars.
644            padding: static_line(&multi),
645        });
646        *ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&log));
647
648        let label_width = names
649            .iter()
650            .map(|n| console::measure_text_width(n))
651            .max()
652            .unwrap_or(0)
653            .min(MAX_NAME_WIDTH)
654            .max("FastQ files".len());
655
656        let bars = if names.len() > MAX_FILE_BARS {
657            // Too many files for a bar each: count completed files instead.
658            let bar = multi.add(ProgressBar::new(names.len() as u64));
659            bar.set_style(aggregate_style());
660            bar.set_prefix(pad_cell(&paint("FastQ files", |s| s.bold()), label_width));
661            Bars::Aggregate(bar)
662        } else {
663            let running = running_style();
664            let bars = names
665                .iter()
666                .map(|name| {
667                    let bar = multi.add(ProgressBar::new(SCALE));
668                    bar.set_style(running.clone());
669                    bar.set_prefix(pad_cell(&paint(name, |s| s.bold()), label_width));
670                    bar.set_message("waiting");
671                    bar.tick();
672                    FileBar {
673                        bar,
674                        started: AtomicBool::new(false),
675                    }
676                })
677                .collect();
678            Bars::PerFile(Arc::new(bars))
679        };
680
681        // The table decides for itself, on every refresh, whether the terminal
682        // is currently wide enough to give every column room to be read, so it
683        // appears and disappears as the window is resized. It is built here
684        // whenever there is anything at all to tabulate.
685        let table = (!names.is_empty()).then(|| Arc::new(Table::new(&multi, names)));
686
687        // The closing summary is the last line of the region, so it always
688        // lands below the bars and the table however the run went. It renders
689        // as nothing until it has a message.
690        let summary = static_line(&multi);
691
692        // A blank line below everything, so the shell prompt does not land
693        // flush against the display.
694        let trailer = static_line(&multi);
695        trailer.set_message(" ");
696
697        let live = Live {
698            bars,
699            table,
700            summary,
701            trailer,
702            log,
703            ticker: Mutex::new(None),
704            stop: Arc::new(AtomicBool::new(false)),
705        };
706        live.start_ticker();
707        live
708    }
709
710    /// Animate the display from a single background thread: the analysis threads
711    /// only ever publish counters and positions, they never render.
712    ///
713    /// indicatif's own `enable_steady_tick` spawns a thread per bar, which for
714    /// a ten-file run means ten threads competing for the same draw lock purely
715    /// to advance a spinner. One thread does both jobs.
716    fn start_ticker(&self) {
717        let table = self.table.as_ref().map(Arc::clone);
718        let bars = self.bars.clone();
719        let stop = Arc::clone(&self.stop);
720        let handle = std::thread::Builder::new()
721            .name("fastqc-progress".into())
722            .spawn(move || {
723                // The table is re-rendered more slowly than the spinners are
724                // advanced, on its own deadline rather than a second thread.
725                let mut due = Instant::now();
726                while !stop.load(Ordering::Relaxed) {
727                    for bar in bars.spinners() {
728                        if !bar.is_finished() {
729                            bar.tick();
730                        }
731                    }
732                    let now = Instant::now();
733                    if now >= due {
734                        due = now + TABLE_REFRESH;
735                        if let Some(table) = &table {
736                            table.refresh();
737                        }
738                    }
739                    std::thread::sleep(SPINNER_TICK);
740                }
741                // One last render so the table shows the finished values.
742                if let Some(table) = &table {
743                    table.refresh();
744                }
745            });
746        if let Ok(handle) = handle {
747            *self.ticker.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
748        }
749    }
750
751    fn bar(&self, index: usize) -> Option<&ProgressBar> {
752        match &self.bars {
753            Bars::PerFile(bars) => bars.get(index).map(|file| &file.bar),
754            Bars::Aggregate(_) => None,
755        }
756    }
757
758    fn start(&self, index: usize) {
759        let Bars::PerFile(bars) = &self.bars else {
760            return;
761        };
762        let Some(file) = bars.get(index) else {
763            return;
764        };
765        file.started.store(true, Ordering::Relaxed);
766        // The bar was created with every other bar, before any file was opened,
767        // and indicatif has been counting since. Restart the clock so the time
768        // this bar reports is the time this file took.
769        file.bar.reset_elapsed();
770        file.bar.set_message("reading");
771    }
772
773    /// Both halves of this are guarded by a comparison against the bar's
774    /// current state, because both `set_position` and `set_message` reach
775    /// indicatif's redraw — which takes the global draw lock and re-formats the
776    /// line — while `position` and `message` only take that one bar's lock.
777    /// This runs once per thousand reads on every file at once, and most calls
778    /// change nothing: the bar has only [`SCALE`] distinct positions, and
779    /// `human_count` is coarse enough that past a million reads the same label
780    /// is produced for a hundred consecutive updates.
781    fn progress(&self, index: usize, reads: u64, percent: impl FnOnce() -> f64) {
782        let Some(bar) = self.bar(index) else {
783            return;
784        };
785        let position = (percent().clamp(0.0, 100.0) / 100.0 * SCALE as f64) as u64;
786        if bar.position() != position {
787            bar.set_position(position);
788        }
789        let label = format!("{} reads", human_count(reads));
790        if bar.message() != label {
791            bar.set_message(label);
792        }
793    }
794
795    fn stage(&self, index: usize, stage: &str) {
796        if let Some(bar) = self.bar(index) {
797            bar.set_position(SCALE);
798            bar.set_message(stage.to_string());
799        }
800    }
801
802    /// Record how a file ended so the table heading can follow its bar.
803    fn set_file_state(&self, index: usize, state: FileState) {
804        if let Some(column) = self.table.as_ref().and_then(|t| t.columns.get(index)) {
805            column.state.store(state as u8, Ordering::Relaxed);
806        }
807    }
808
809    fn finish_file(&self, index: usize, reads: u64) {
810        self.set_file_state(index, FileState::Analysed);
811        match &self.bars {
812            Bars::PerFile(bars) => {
813                if let Some(file) = bars.get(index) {
814                    file.bar.set_style(done_style());
815                    file.bar.set_position(SCALE);
816                    file.bar
817                        .set_message(format!("{} reads", human_count(reads)));
818                    file.bar.finish();
819                }
820            }
821            Bars::Aggregate(bar) => bar.inc(1),
822        }
823    }
824
825    fn fail_file(&self, index: usize) {
826        self.set_file_state(index, FileState::Failed);
827        match &self.bars {
828            Bars::PerFile(bars) => {
829                if let Some(file) = bars.get(index) {
830                    file.bar.set_style(failed_style());
831                    file.bar.set_message("failed");
832                    file.bar.abandon();
833                }
834            }
835            Bars::Aggregate(bar) => bar.inc(1),
836        }
837    }
838
839    fn finish(&self, analysed: usize) {
840        if let Bars::Aggregate(bar) = &self.bars {
841            bar.set_style(if bar.length() == Some(analysed as u64) {
842                aggregate_done_style()
843            } else {
844                aggregate_failed_style()
845            });
846            bar.finish();
847        }
848        self.shut_down();
849        // indicatif erases any bar that is still unfinished when it is dropped,
850        // so the static lines have to be explicitly finished for the completed
851        // display to survive the end of the run.
852        if let Some(table) = &self.table {
853            table.finish();
854        }
855        self.log.padding.finish();
856        self.summary.finish();
857        self.trailer.finish();
858    }
859
860    /// Stop the redraw thread and detach this display from the log sink.
861    /// Idempotent, so [`Drop`] can repeat it after [`Live::finish`].
862    fn shut_down(&self) {
863        self.stop.store(true, Ordering::Relaxed);
864        if let Some(handle) = self.ticker.lock().unwrap_or_else(|e| e.into_inner()).take() {
865            let _ = handle.join();
866        }
867        let mut active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner());
868        if active
869            .as_ref()
870            .is_some_and(|sink| Arc::ptr_eq(sink, &self.log))
871        {
872            *active = None;
873        }
874    }
875}
876
877/// A run that unwinds before [`Live::finish`] must not leave the ticker
878/// redrawing a dead display, or log lines routed into it.
879impl Drop for Live {
880    fn drop(&mut self) {
881        self.shut_down();
882    }
883}
884
885/// Add a line of static text to the redrawn region. Implemented as a progress
886/// bar that renders nothing but its message, which is how indicatif keeps a
887/// block of text pinned below the bars.
888fn static_line(multi: &MultiProgress) -> ProgressBar {
889    let line = multi.add(ProgressBar::new(0));
890    line.set_style(ProgressStyle::with_template("{msg}").expect("static template"));
891    line.tick();
892    line
893}
894
895/// A stderr terminal that reports a usable size even when stderr is not a tty.
896///
897/// `console::Term` writes its cursor-movement and clear-line escapes
898/// unconditionally, so it drives the display fine over a pipe; what it cannot
899/// do is measure a pipe, and it falls back to a hardcoded 80 columns. That
900/// would leave `{wide_bar}` sized differently from the statistics table, which
901/// measures itself. Both go through this type instead, so they agree — and
902/// `COLUMNS`/`LINES` give a forced run (a recording, a CI job) a way to say how
903/// wide the output should be.
904#[derive(Debug)]
905struct ForcedTerm {
906    inner: Term,
907    width: u16,
908    height: u16,
909}
910
911/// How wide the display may draw, right now.
912///
913/// Measured on every table refresh rather than once at startup, so a window
914/// resized mid-run is followed rather than ignored. `COLUMNS` covers the case
915/// where stderr cannot be measured because it is not a terminal at all
916/// (`FASTQC_PROGRESS=always` over a pipe).
917fn measured_term_width() -> usize {
918    Term::stderr()
919        .size_checked()
920        .map(|(_, cols)| cols)
921        .or_else(|| env_dimension("COLUMNS"))
922        .unwrap_or(DEFAULT_TERM_WIDTH as u16) as usize
923}
924
925impl ForcedTerm {
926    fn new() -> Self {
927        // Buffered, like the `Term` behind indicatif's own stderr draw target.
928        // It matters: indicatif emits a frame as a run of writes and flushes at
929        // the end, and with an unbuffered terminal a concurrent draw can land
930        // in the middle of one, running two bar lines onto the same row.
931        let inner = Term::buffered_stderr();
932        let measured = inner.size_checked();
933        ForcedTerm {
934            width: measured_term_width() as u16,
935            height: measured
936                .map(|(rows, _)| rows)
937                .or_else(|| env_dimension("LINES"))
938                .unwrap_or(DEFAULT_TERM_HEIGHT),
939            inner,
940        }
941    }
942
943    /// `ESC [ n <op>`, the cursor-movement form. A zero count is a no-op
944    /// rather than an escape, matching what console would emit.
945    fn escape(&self, n: usize, op: char) -> std::io::Result<()> {
946        if n == 0 {
947            return Ok(());
948        }
949        self.inner.write_str(&format!("\x1b[{n}{op}"))
950    }
951}
952
953fn env_dimension(name: &str) -> Option<u16> {
954    std::env::var(name)
955        .ok()?
956        .parse::<u16>()
957        .ok()
958        .filter(|n| *n > 0)
959}
960
961impl TermLike for ForcedTerm {
962    fn width(&self) -> u16 {
963        self.width
964    }
965
966    fn height(&self) -> u16 {
967        self.height
968    }
969
970    // Cursor movement is written as ANSI rather than delegated to `Term`.
971    // console drives a real Windows console through the Win32 API, which
972    // silently does nothing when the handle is a pipe — so delegating would
973    // draw every frame and erase none, leaving one long concatenation. This
974    // type is only used when the display has been forced onto something that
975    // is not a terminal, where the consumer is a recorder or a log viewer that
976    // interprets escapes, so emitting them is exactly right. On Unix these are
977    // the same bytes `Term` would have written.
978    fn move_cursor_up(&self, n: usize) -> std::io::Result<()> {
979        self.escape(n, 'A')
980    }
981
982    fn move_cursor_down(&self, n: usize) -> std::io::Result<()> {
983        self.escape(n, 'B')
984    }
985
986    fn move_cursor_right(&self, n: usize) -> std::io::Result<()> {
987        self.escape(n, 'C')
988    }
989
990    fn move_cursor_left(&self, n: usize) -> std::io::Result<()> {
991        self.escape(n, 'D')
992    }
993
994    fn write_line(&self, s: &str) -> std::io::Result<()> {
995        self.inner.write_line(s)
996    }
997
998    fn write_str(&self, s: &str) -> std::io::Result<()> {
999        self.inner.write_str(s)
1000    }
1001
1002    fn clear_line(&self) -> std::io::Result<()> {
1003        self.inner.write_str("\r\x1b[2K")
1004    }
1005
1006    fn flush(&self) -> std::io::Result<()> {
1007        self.inner.flush()
1008    }
1009}
1010
1011/// The live Basic Statistics table shown underneath the progress bars.
1012///
1013/// The whole table is a *single* zero-length progress bar whose message spans
1014/// several lines — indicatif splits a message on newlines and accounts for
1015/// every line. One bar rather than one per row matters: a refresh is then a
1016/// single message update and so a single redraw, instead of a dozen redraws of
1017/// the entire display several times a second, which churns the terminal and
1018/// races with anything trying to print above it.
1019struct Table {
1020    line: ProgressBar,
1021    columns: Vec<Column>,
1022    /// The file names, kept so the geometry can be rebuilt at a new width.
1023    names: Vec<String>,
1024    state: Mutex<TableState>,
1025}
1026
1027struct TableState {
1028    /// Terminal width [`Geometry`] was built for. A run whose window is resized
1029    /// rebuilds at the new width rather than drawing a table cut to the old
1030    /// one; `{wide_bar}` above it already reflows on its own.
1031    width: usize,
1032    /// `None` when the terminal is too narrow for the table to be worth
1033    /// drawing, which is also how it disappears and comes back as the window is
1034    /// resized across the threshold.
1035    geometry: Option<Geometry>,
1036    /// The last frame rendered, so an unchanged one can be dropped rather than
1037    /// pushed through indicatif and out to the terminal again.
1038    last: String,
1039}
1040
1041/// Everything about the table that depends on how wide it may be, rendered once
1042/// per width rather than once per frame: the three borders, the styled vertical
1043/// rule, the heading and measure label cells, and each file's heading in each
1044/// of its three states.
1045struct Geometry {
1046    value_width: usize,
1047    top: String,
1048    divider: String,
1049    bottom: String,
1050    pipe: String,
1051    heading_label: String,
1052    /// One per measure, in report order — so this also fixes the row count.
1053    row_labels: Vec<String>,
1054    /// Per column, the heading pre-rendered in each state indexed by
1055    /// [`FileState`]: the colour is the only thing that varies, and re-styling
1056    /// it per frame would be pure waste.
1057    headings: Vec<[String; 3]>,
1058}
1059
1060struct Column {
1061    live: Arc<LiveStats>,
1062    /// Drives the heading colour, kept in step with the file's bar.
1063    state: AtomicU8,
1064}
1065
1066/// How a file is doing, for colouring its column heading the same way its
1067/// progress bar is coloured. The discriminant indexes [`Column::headings`].
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069enum FileState {
1070    /// Waiting or being read.
1071    Running = 0,
1072    Analysed = 1,
1073    Failed = 2,
1074}
1075
1076impl Geometry {
1077    /// Lay a table of `names` out for a terminal of `term_width`, or `None`
1078    /// when it is too narrow to be worth drawing — [`layout`] decides both.
1079    fn new(names: &[String], term_width: usize) -> Option<Self> {
1080        let (label_width, value_width) = layout(names.len(), term_width)?;
1081
1082        let rule = |left: char, mid: char, right: char| {
1083            let mut s = String::from("  ");
1084            s.push(left);
1085            s.push_str(&"─".repeat(label_width + 2));
1086            for _ in names {
1087                s.push(mid);
1088                s.push_str(&"─".repeat(value_width + 2));
1089            }
1090            s.push(right);
1091            paint(&s, |st| st.dim())
1092        };
1093
1094        Some(Geometry {
1095            value_width,
1096            top: rule('┌', '┬', '┐'),
1097            divider: rule('├', '┼', '┤'),
1098            bottom: rule('└', '┴', '┘'),
1099            pipe: paint("│", |s| s.dim()),
1100            heading_label: pad_cell(&paint("Measure", |s| s.dim()), label_width),
1101            row_labels: BasicStatsCounters::MEASURES
1102                .iter()
1103                .map(|m| pad_cell(m, label_width))
1104                .collect(),
1105            headings: names
1106                .iter()
1107                // The heading tracks the file's bar: accent while it is
1108                // running, green once analysed, red if it failed.
1109                .map(|name| {
1110                    [
1111                        pad_cell(&paint(name, |s| s.cyan().bold()), value_width),
1112                        pad_cell(&paint(name, |s| s.green().bold()), value_width),
1113                        pad_cell(&paint(name, |s| s.red().bold()), value_width),
1114                    ]
1115                })
1116                .collect(),
1117        })
1118    }
1119
1120    /// A content line, from cells that are already styled and padded.
1121    fn row<'a>(&self, label_cell: &str, cells: impl Iterator<Item = &'a str>) -> String {
1122        let mut s = String::from("  ");
1123        s.push_str(&self.pipe);
1124        s.push(' ');
1125        s.push_str(label_cell);
1126        s.push(' ');
1127        for cell in cells {
1128            s.push_str(&self.pipe);
1129            s.push(' ');
1130            s.push_str(cell);
1131            s.push(' ');
1132        }
1133        s.push_str(&self.pipe);
1134        s
1135    }
1136}
1137
1138impl Table {
1139    fn new(multi: &MultiProgress, names: &[String]) -> Self {
1140        let columns = names
1141            .iter()
1142            .map(|_| Column {
1143                live: Arc::new(LiveStats::new()),
1144                state: AtomicU8::new(FileState::Running as u8),
1145            })
1146            .collect::<Vec<_>>();
1147
1148        let width = measured_term_width();
1149        let table = Table {
1150            line: static_line(multi),
1151            columns,
1152            names: names.to_vec(),
1153            state: Mutex::new(TableState {
1154                width,
1155                geometry: Geometry::new(names, width),
1156                last: String::new(),
1157            }),
1158        };
1159        table.refresh();
1160        table
1161    }
1162
1163    /// Mark the table finished so it is not erased when the progress bars
1164    /// behind it are dropped at the end of the run.
1165    fn finish(&self) {
1166        self.line.finish();
1167    }
1168
1169    /// Re-render the table from the latest published counters.
1170    ///
1171    /// Only the value cells are built here; everything that depends on the
1172    /// width alone lives in [`Geometry`] and is rebuilt only when the terminal
1173    /// is resized. The result is compared against the last frame and dropped if
1174    /// identical, which is the common case — columns only change every few
1175    /// thousand reads, and nothing changes at all while the reports are being
1176    /// written.
1177    fn refresh(&self) {
1178        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
1179
1180        let width = measured_term_width();
1181        if width != state.width {
1182            state.width = width;
1183            state.geometry = Geometry::new(&self.names, width);
1184        }
1185
1186        let Some(geometry) = &state.geometry else {
1187            // Too narrow for a readable table. Rendering nothing rather than
1188            // something cut off, and indicatif drops the line entirely.
1189            if !state.last.is_empty() {
1190                state.last = String::new();
1191                self.line.set_message("");
1192            }
1193            return;
1194        };
1195
1196        let mut out: Vec<String> = Vec::with_capacity(geometry.row_labels.len() + 5);
1197        // A single space rather than an empty string: indicatif skips lines
1198        // that render to nothing, and the spacer is wanted.
1199        out.push(" ".to_string());
1200        out.push(geometry.top.clone());
1201        out.push(geometry.row(
1202            &geometry.heading_label,
1203            self.columns.iter().enumerate().map(|(index, column)| {
1204                let file_state = column.state.load(Ordering::Relaxed) as usize;
1205                let headings = &geometry.headings[index];
1206                headings.get(file_state).unwrap_or(&headings[0]).as_str()
1207            }),
1208        ));
1209        out.push(geometry.divider.clone());
1210
1211        // A column that has not published yet shows "-" everywhere, so an idle
1212        // file reads as idle rather than as a file of zero reads.
1213        let values: Vec<Vec<String>> = self
1214            .columns
1215            .iter()
1216            .map(|column| {
1217                let snapshot = column.live.snapshot();
1218                column.live.request();
1219                match snapshot {
1220                    None => vec!["-".to_string(); geometry.row_labels.len()],
1221                    Some(counters) => counters.rows().into_iter().map(|(_, v)| v).collect(),
1222                }
1223            })
1224            .collect();
1225
1226        // Row labels and ordering come straight from the report's own table.
1227        for (index, label) in geometry.row_labels.iter().enumerate() {
1228            let cells: Vec<String> = values
1229                .iter()
1230                .map(|column| pad_cell(&paint(&column[index], |s| s.white()), geometry.value_width))
1231                .collect();
1232            out.push(geometry.row(label, cells.iter().map(String::as_str)));
1233        }
1234        out.push(geometry.bottom.clone());
1235
1236        let rendered = out.join("\n");
1237        if state.last == rendered {
1238            return;
1239        }
1240        // One update, one redraw.
1241        self.line.set_message(rendered.clone());
1242        state.last = rendered;
1243    }
1244}
1245
1246/// Terminal columns consumed by a table of `columns` files that are not cell
1247/// content: two of indentation, a border between and either side of every
1248/// cell, and a space either side of each.
1249fn table_overhead(columns: usize) -> usize {
1250    2 + (columns + 2) + 2 * (columns + 1)
1251}
1252
1253/// The `(label_width, value_width)` of a table for `columns` files at this
1254/// terminal width, or `None` when it is not worth drawing — which is when the
1255/// measure column cannot have its natural width or a value column would be
1256/// narrower than [`MIN_VALUE_WIDTH`].
1257///
1258/// This is the single place the geometry is decided; returning `None` is also
1259/// how the display decides not to show the table at all.
1260fn layout(columns: usize, term_width: usize) -> Option<(usize, usize)> {
1261    if columns == 0 {
1262        return None;
1263    }
1264    let label_width = BasicStatsCounters::MEASURES
1265        .iter()
1266        .map(|m| console::measure_text_width(m))
1267        .max()
1268        .unwrap_or(8);
1269    let available = term_width
1270        .saturating_sub(table_overhead(columns))
1271        .checked_sub(label_width)?;
1272
1273    let value_width = (available / columns).min(MAX_VALUE_WIDTH);
1274    (value_width >= MIN_VALUE_WIDTH).then_some((label_width, value_width))
1275}
1276
1277/// Fit a cell to exactly `width` columns, padding it out or truncating it with
1278/// an ellipsis. `console::pad_str` measures with `measure_text_width`, which
1279/// skips ANSI escapes, so a styled cell lines up with a plain one and is cut
1280/// without losing its trailing reset.
1281fn pad_cell(text: &str, width: usize) -> String {
1282    console::pad_str(text, width, console::Alignment::Left, Some("…")).into_owned()
1283}
1284
1285/// Style an error line red for stderr.
1286fn paint_error(text: &str) -> String {
1287    paint(text, |s| s.red())
1288}
1289
1290/// Style text for stderr.
1291///
1292/// `for_stderr` makes console resolve the escape codes against
1293/// `colors_enabled_stderr()` when the value is rendered, which is the same
1294/// signal indicatif's template styling uses — so there is nothing for this
1295/// module to decide or thread around.
1296fn paint(
1297    text: &str,
1298    apply: impl FnOnce(console::StyledObject<&str>) -> console::StyledObject<&str>,
1299) -> String {
1300    apply(style(text).for_stderr()).to_string()
1301}
1302
1303/// Template key rendering the elapsed time compactly and dimmed, shared by
1304/// every bar style so the last column always looks the same.
1305fn elapsed_key(
1306) -> impl Fn(&indicatif::ProgressState, &mut dyn std::fmt::Write) + Clone + Send + Sync + 'static {
1307    move |state: &indicatif::ProgressState, w: &mut dyn std::fmt::Write| {
1308        let _ = write!(
1309            w,
1310            "{}",
1311            paint(&short_duration(state.elapsed()), |s| s.dim())
1312        );
1313    }
1314}
1315
1316/// The FastQC logo's two colours, as the nearest xterm-256 entries.
1317///
1318/// Taken from the dark-background variant of the logo
1319/// (`docs/public/images/fastqc_logo_darkbg.svg`, `#659BFF` and `#AE3939`)
1320/// rather than the light-background one, whose navy `#000080` all but
1321/// disappears against a dark terminal. These mid-tones read on either.
1322///
1323/// Both are the closest entries in the 6x6x6 cube by CIELAB distance —
1324/// `#5f87ff` and `#d75f5f`. 256-colour rather than truecolor so that the one
1325/// code path works on every terminal; console emits 24-bit escapes without
1326/// checking whether the terminal can render them.
1327///
1328/// Public so the test that checks the banner is actually painted in them can
1329/// name them rather than restate the escape sequences.
1330pub const LOGO_BLUE: u8 = 69;
1331pub const LOGO_RED: u8 = 167;
1332
1333/// Bar characters chosen to match the heavy-line look of Python's `rich`.
1334const PROGRESS_CHARS: &str = "━╸━";
1335
1336/// Spinner frames. Inert for the styles whose template has no `{spinner}`.
1337const TICK_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
1338
1339/// Build a bar style. The display's styles differ only in the marker before
1340/// the bar, the bar's colour, and the field between the bar and the elapsed
1341/// time; everything else — the column layout, the bar characters and the
1342/// elapsed-time key — is shared, so that a change to the layout is made once.
1343///
1344/// `marker` and `middle` are substituted into the template, and `format!` does
1345/// not re-scan a substituted value for braces, so both can carry placeholders
1346/// of their own.
1347fn bar_style(marker: &str, color: &str, middle: &str) -> ProgressStyle {
1348    ProgressStyle::with_template(&format!(
1349        "  {{prefix}} {marker} {{wide_bar:.{color}/238}} {middle} {{elapsed:>5}}"
1350    ))
1351    .expect("static template")
1352    .progress_chars(PROGRESS_CHARS)
1353    .tick_chars(TICK_CHARS)
1354    .with_key("elapsed", elapsed_key())
1355}
1356
1357/// The per-file fields: percentage, then a short status or read count.
1358const FILE_FIELDS: &str = "{percent:>3}% {msg:<12}";
1359/// The aggregate bar counts files rather than tracking one.
1360const AGGREGATE_FIELDS: &str = "{pos}/{len} files";
1361
1362fn running_style() -> ProgressStyle {
1363    bar_style("{spinner:.cyan}", "cyan", FILE_FIELDS)
1364}
1365
1366fn done_style() -> ProgressStyle {
1367    bar_style(&paint("✔", |s| s.green().bold()), "green", FILE_FIELDS)
1368}
1369
1370fn failed_style() -> ProgressStyle {
1371    // Same column layout as the running and finished styles so a failed file
1372    // does not knock the other bars out of alignment. The bar is left at
1373    // whatever fraction of the file had been read when the error hit.
1374    bar_style(
1375        &paint("✘", |s| s.red().bold()),
1376        "red",
1377        &format!("{{percent:>3}}% {}", paint("{msg:<12}", |s| s.red())),
1378    )
1379}
1380
1381fn aggregate_style() -> ProgressStyle {
1382    bar_style("{spinner:.cyan}", "cyan", AGGREGATE_FIELDS)
1383}
1384
1385fn aggregate_done_style() -> ProgressStyle {
1386    bar_style(&paint("✔", |s| s.green().bold()), "green", AGGREGATE_FIELDS)
1387}
1388
1389fn aggregate_failed_style() -> ProgressStyle {
1390    bar_style(&paint("✘", |s| s.red().bold()), "red", AGGREGATE_FIELDS)
1391}
1392
1393/// Wall-clock elapsed time for the closing summary: `mm:ss`, widening to
1394/// `hh:mm:ss` past an hour rather than letting the minutes run past 59.
1395fn clock_duration(d: Duration) -> String {
1396    let secs = d.as_secs();
1397    if secs < 3600 {
1398        format!("{:02}:{:02}", secs / 60, secs % 60)
1399    } else {
1400        format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
1401    }
1402}
1403
1404/// Compact elapsed time: `4.2s`, `1m12s`, `1h04m`.
1405fn short_duration(d: Duration) -> String {
1406    // Rounded before choosing the unit, so 59.96s reads `1m00s`, not `60.0s`.
1407    let tenths = (d.as_secs_f64() * 10.0).round() as u64;
1408    if tenths < 600 {
1409        return format!("{}.{}s", tenths / 10, tenths % 10);
1410    }
1411    let secs = d.as_secs().max(60);
1412    if secs < 3600 {
1413        format!("{}m{:02}s", secs / 60, secs % 60)
1414    } else {
1415        format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
1416    }
1417}
1418
1419/// Abbreviate a read count for display: `812`, `12.3k`, `3.0M`.
1420///
1421/// The thresholds are set where the *rounded* value would tip over rather than
1422/// at the round number, so a count just short of the next unit reads as `1.0M`
1423/// and not `1000.0k`.
1424fn human_count(n: u64) -> String {
1425    if n >= 999_950_000 {
1426        format!("{:.1}B", n as f64 / 1e9)
1427    } else if n >= 999_950 {
1428        format!("{:.1}M", n as f64 / 1e6)
1429    } else if n >= 1_000 {
1430        format!("{:.1}k", n as f64 / 1e3)
1431    } else {
1432        n.to_string()
1433    }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439
1440    #[test]
1441    fn test_human_count() {
1442        assert_eq!(human_count(0), "0");
1443        assert_eq!(human_count(999), "999");
1444        assert_eq!(human_count(1_500), "1.5k");
1445        assert_eq!(human_count(3_000_000), "3.0M");
1446        assert_eq!(human_count(2_500_000_000), "2.5B");
1447
1448        // Just short of the next unit: the count must roll over rather than
1449        // print a mantissa that has rounded past the unit it is labelled with.
1450        assert_eq!(human_count(999_999), "1.0M");
1451        assert_eq!(human_count(999_999_999), "1.0B");
1452        // ...and just below the rollover it stays put.
1453        assert_eq!(human_count(999_949), "999.9k");
1454        assert_eq!(human_count(999_949_999), "999.9M");
1455    }
1456
1457    #[test]
1458    fn test_short_duration() {
1459        assert_eq!(short_duration(Duration::from_millis(4200)), "4.2s");
1460        assert_eq!(short_duration(Duration::from_millis(59_960)), "1m00s");
1461        assert_eq!(short_duration(Duration::from_secs(72)), "1m12s");
1462        assert_eq!(short_duration(Duration::from_secs(3840)), "1h04m");
1463    }
1464
1465    #[test]
1466    fn test_when_parse() {
1467        for on in [
1468            "always", "ALWAYS", " always ", "force", "1", "yes", "true", "on",
1469        ] {
1470            assert_eq!(When::parse(on), When::Always, "{on:?}");
1471        }
1472        for off in ["never", "Never", "none", "0", "no", "false", "off"] {
1473            assert_eq!(When::parse(off), When::Never, "{off:?}");
1474        }
1475        // Anything unrecognised falls back to detection rather than failing the
1476        // run over a display preference.
1477        for other in ["", "auto", "maybe", "yes please"] {
1478            assert_eq!(When::parse(other), When::Auto, "{other:?}");
1479        }
1480    }
1481
1482    /// Auto-detection: the live display needs both a tty and a terminal that
1483    /// can be redrawn.
1484    #[test]
1485    fn test_choose_mode_auto() {
1486        let auto = When::Auto;
1487        // is_terminal, dumb
1488        assert_eq!(
1489            choose_mode(false, auto, true, false),
1490            ModeChoice::Live {
1491                bypass_detection: false
1492            }
1493        );
1494        // Piped or redirected stderr: plain lines, never bars.
1495        assert_eq!(choose_mode(false, auto, false, false), ModeChoice::Plain);
1496        // A tty that cannot redraw (TERM=dumb, or unset on Unix): indicatif
1497        // would hide the bars, so fall back rather than going silent.
1498        assert_eq!(choose_mode(false, auto, true, true), ModeChoice::Plain);
1499        assert_eq!(choose_mode(false, auto, false, true), ModeChoice::Plain);
1500    }
1501
1502    /// `FASTQC_PROGRESS=always|never` overrides the detection in both
1503    /// directions. `always` bypasses indicatif's self-hiding draw target only
1504    /// where that target would in fact hide — driving the terminal by hand on a
1505    /// terminal that works would be a downgrade, not a force.
1506    #[test]
1507    fn test_choose_mode_forced() {
1508        // is_terminal, dumb, and whether the display has to be driven by hand.
1509        // Spelled out rather than recomputed from the rule, so that getting the
1510        // rule wrong fails the test instead of being copied into it.
1511        for (is_terminal, dumb, bypass_detection) in [
1512            (true, false, false),
1513            (true, true, true),
1514            (false, false, true),
1515            (false, true, true),
1516        ] {
1517            assert_eq!(
1518                choose_mode(false, When::Always, is_terminal, dumb),
1519                ModeChoice::Live { bypass_detection },
1520                "always must draw the display (tty={is_terminal}, dumb={dumb})"
1521            );
1522            assert_eq!(
1523                choose_mode(false, When::Never, is_terminal, dumb),
1524                ModeChoice::Plain,
1525                "never must not draw the display (tty={is_terminal}, dumb={dumb})"
1526            );
1527        }
1528    }
1529
1530    /// `--quiet` is the stronger statement and beats an explicit `always`.
1531    #[test]
1532    fn test_quiet_beats_forced_progress() {
1533        for progress in [When::Auto, When::Always, When::Never] {
1534            for &is_terminal in &[true, false] {
1535                assert_eq!(
1536                    choose_mode(true, progress, is_terminal, false),
1537                    ModeChoice::Silent,
1538                    "--quiet must win over {progress:?}"
1539                );
1540            }
1541        }
1542    }
1543
1544    /// With no display active, a log line still reaches stderr rather than
1545    /// being swallowed.
1546    #[test]
1547    fn test_log_line_without_a_display() {
1548        assert!(ACTIVE_LOG
1549            .lock()
1550            .unwrap_or_else(|e| e.into_inner())
1551            .is_none());
1552        log_line("no display active, so this goes straight to stderr");
1553    }
1554
1555    /// Whenever the table is shown, it must fit inside the terminal, with the
1556    /// measure column at its natural width and every value column readable.
1557    #[test]
1558    fn test_a_shown_table_renders_inside_the_terminal() {
1559        let natural_label = BasicStatsCounters::MEASURES
1560            .iter()
1561            .map(|m| console::measure_text_width(m))
1562            .max()
1563            .unwrap();
1564        for term_width in [40usize, 60, 72, 80, 100, 120, 160, 200, 400] {
1565            for columns in 1..=12 {
1566                let Some((label_width, value_width)) = layout(columns, term_width) else {
1567                    continue;
1568                };
1569                let total = table_overhead(columns) + label_width + value_width * columns;
1570                assert!(
1571                    total <= term_width,
1572                    "shown table of {columns} columns overflows {term_width} cols (needs {total})"
1573                );
1574                assert!(
1575                    value_width >= MIN_VALUE_WIDTH,
1576                    "value column below the readable minimum at {term_width} cols"
1577                );
1578                assert_eq!(
1579                    label_width, natural_label,
1580                    "measure column was squeezed at {term_width} cols"
1581                );
1582            }
1583        }
1584    }
1585
1586    /// The decision is made on width, not on how many files there are: a wider
1587    /// terminal earns more columns, a narrow one loses the table entirely.
1588    #[test]
1589    fn test_table_visibility_follows_terminal_width() {
1590        let fits = |columns, width| layout(columns, width).is_some();
1591
1592        // Nothing to tabulate.
1593        assert!(!fits(0, 200));
1594
1595        // 80 columns is enough for up to three files, not four.
1596        assert!(fits(1, 80));
1597        assert!(fits(3, 80));
1598        assert!(!fits(4, 80));
1599
1600        // Widening the terminal brings more columns into range, and the
1601        // threshold only ever moves one way.
1602        for columns in 1..=10 {
1603            // The first width that fits. Asserting that narrower ones do not
1604            // would be vacuous — `find` returns the smallest by definition.
1605            // What has to hold is the other direction: every wider terminal
1606            // fits too, so the table never blinks out again as the window
1607            // grows.
1608            let threshold = (1..600)
1609                .find(|w| fits(columns, *w))
1610                .expect("some width is wide enough");
1611            assert!(
1612                (threshold..600).all(|w| fits(columns, w)),
1613                "{columns} columns: fits at {threshold} but not at every wider width"
1614            );
1615            // More files always need at least as much room.
1616            if columns > 1 {
1617                let narrower = (1..600).find(|w| fits(columns - 1, *w)).unwrap();
1618                assert!(narrower < threshold);
1619            }
1620        }
1621
1622        // A 24-column terminal is never wide enough.
1623        assert!(!fits(1, 24));
1624    }
1625
1626    /// A cell always occupies exactly its column, whether it had to be padded
1627    /// out or cut down, and styling it does not change that: widths are
1628    /// measured in display columns, so the escapes do not count.
1629    #[test]
1630    fn test_pad_cell_fits_the_column_around_ansi() {
1631        let styled = style("abc").red().force_styling(true).to_string();
1632        let padded = pad_cell(&styled, 6);
1633        assert_eq!(console::measure_text_width(&padded), 6);
1634        assert!(padded.starts_with(&styled));
1635
1636        let long = style("abcdefghij").red().force_styling(true).to_string();
1637        let cut = pad_cell(&long, 6);
1638        assert_eq!(console::measure_text_width(&cut), 6);
1639        assert!(cut.contains('…'), "not truncated with an ellipsis: {cut:?}");
1640        assert!(cut.ends_with("\u{1b}[0m"), "lost its reset: {cut:?}");
1641    }
1642
1643    /// The blank line that separates log messages from the bars appears the
1644    /// first time something is logged, and not before: a run that logs nothing
1645    /// should not gain a stray gap above its bars.
1646    #[test]
1647    fn test_log_padding_appears_with_the_first_message() {
1648        let multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
1649        let sink = LogSink {
1650            multi: multi.clone(),
1651            line_ending: "\n",
1652            padding: static_line(&multi),
1653        };
1654        assert_eq!(
1655            sink.padding.message(),
1656            "",
1657            "padded before anything was said"
1658        );
1659
1660        sink.print("something happened");
1661        assert_eq!(sink.padding.message(), " ", "no padding after a message");
1662
1663        // Still exactly one blank line, however much is logged.
1664        sink.print("and again");
1665        assert_eq!(sink.padding.message(), " ");
1666    }
1667
1668    /// The table's furniture is rebuilt for whatever width it is asked for, so
1669    /// a window resized mid-run gets a table laid out for the new size rather
1670    /// than one cut to the old one — and the table drops out entirely, and
1671    /// comes back, as the width crosses the readable threshold.
1672    #[test]
1673    fn test_geometry_follows_the_width_it_is_built_for() {
1674        let names = vec![
1675            "sample_1.fastq.gz".to_string(),
1676            "sample_2.fastq.gz".to_string(),
1677        ];
1678
1679        let narrow = Geometry::new(&names, 50);
1680        assert!(narrow.is_none(), "two columns cannot be readable at 50");
1681
1682        let wide = Geometry::new(&names, 200).expect("two columns fit at 200");
1683        let wider = Geometry::new(&names, 400).expect("two columns fit at 400");
1684        assert_eq!(wide.headings.len(), names.len());
1685
1686        // Every rendered row is exactly as wide as the border above it, and no
1687        // wider than the terminal it was laid out for.
1688        for (geometry, term_width) in [(&wide, 200usize), (&wider, 400)] {
1689            let heading = geometry.row(
1690                &geometry.heading_label,
1691                geometry.headings.iter().map(|h| h[0].as_str()),
1692            );
1693            for line in [&geometry.top, &geometry.divider, &geometry.bottom, &heading] {
1694                assert_eq!(
1695                    console::measure_text_width(line),
1696                    console::measure_text_width(&geometry.top),
1697                    "table lines disagree on width at {term_width} cols"
1698                );
1699                assert!(
1700                    console::measure_text_width(line) <= term_width,
1701                    "table overflows {term_width} cols"
1702                );
1703            }
1704        }
1705
1706        // A value column never grows past its cap, however wide the window.
1707        assert_eq!(wider.value_width, MAX_VALUE_WIDTH);
1708    }
1709
1710    /// A hidden reporter must be safe to drive exactly like a live one.
1711    #[test]
1712    fn test_hidden_reporter_is_inert() {
1713        let reporter = ProgressReporter::hidden();
1714        let file = reporter.file(0);
1715        file.start("a.fastq");
1716        file.update(1000, || 50.0);
1717        file.stage("writing report");
1718        file.finish("a.fastq", 2000);
1719        file.fail();
1720        assert!(file.live_stats().is_none());
1721        reporter.finish(1, false);
1722    }
1723}