refactor(cli): Replace tracing-indicatif with shared MultiProgress

tracing-indicatif pulled in vt100, arrayvec, and its own indicatif
integration layer. Replace it with a minimal SuspendingWriter that
coordinates tracing output with progress bars via a global LazyLock
MultiProgress.

- Add src/cli/progress.rs: shared MultiProgress singleton via LazyLock
  and a SuspendingWriter that suspends bars before writing log lines,
  preventing interleaving/flicker
- Wire all progress bar creation through multi().add() in sync and
  ingest commands
- Replace IndicatifLayer in main.rs with SuspendingWriter for
  tracing-subscriber's fmt layer
- Remove tracing-indicatif from Cargo.toml (drops vt100 and arrayvec
  transitive deps)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-03 17:36:31 -05:00
parent a92e176bb6
commit c35f485e0e
6 changed files with 125 additions and 48 deletions

119
src/cli/progress.rs Normal file
View File

@@ -0,0 +1,119 @@
//! Shared progress bar infrastructure.
//!
//! All progress bars must be created via [`multi()`] to ensure coordinated
//! rendering. The [`SuspendingWriter`] suspends the multi-progress before
//! writing tracing output, preventing log lines from interleaving with
//! progress bar animations.
use indicatif::MultiProgress;
use std::io::Write;
use std::sync::LazyLock;
use tracing_subscriber::fmt::MakeWriter;
/// Global multi-progress that coordinates all progress bar rendering.
///
/// Every `ProgressBar` displayed to the user **must** be registered via
/// `multi().add(bar)`. Standalone bars bypass the coordination and will
/// fight with other bars for the terminal line, causing rapid flashing.
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
/// Returns the shared [`MultiProgress`] instance.
pub fn multi() -> &'static MultiProgress {
&MULTI
}
/// A tracing `MakeWriter` that suspends the shared [`MultiProgress`] while
/// writing, so log output doesn't interleave with progress bar animations.
///
/// # How it works
///
/// `MultiProgress::suspend` temporarily clears all active progress bars from
/// the terminal, executes the closure (which writes the log line), then
/// redraws the bars. This ensures a clean, flicker-free display even when
/// logging happens concurrently with progress updates.
#[derive(Clone)]
pub struct SuspendingWriter;
/// Writer returned by [`SuspendingWriter`] that buffers a single log line
/// and flushes it inside a `MultiProgress::suspend` call.
pub struct SuspendingWriterInner {
buf: Vec<u8>,
}
impl Write for SuspendingWriterInner {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
// Nothing to do — actual flush happens on drop.
Ok(())
}
}
impl Drop for SuspendingWriterInner {
fn drop(&mut self) {
if !self.buf.is_empty() {
let buf = std::mem::take(&mut self.buf);
MULTI.suspend(|| {
let _ = std::io::stderr().write_all(&buf);
let _ = std::io::stderr().flush();
});
}
}
}
impl<'a> MakeWriter<'a> for SuspendingWriter {
type Writer = SuspendingWriterInner;
fn make_writer(&'a self) -> Self::Writer {
SuspendingWriterInner { buf: Vec::new() }
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatif::ProgressBar;
#[test]
fn multi_returns_same_instance() {
let a = multi() as *const MultiProgress;
let b = multi() as *const MultiProgress;
assert!(std::ptr::eq(a, b));
}
#[test]
fn added_bar_is_tracked() {
let bar = multi().add(ProgressBar::new_spinner());
bar.set_message("test");
bar.finish_and_clear();
}
#[test]
fn hidden_bar_works_with_multi() {
let bar = multi().add(ProgressBar::hidden());
bar.set_message("hidden");
bar.finish_and_clear();
}
#[test]
fn suspending_writer_buffers_and_flushes() {
let writer = SuspendingWriter;
let mut w = MakeWriter::make_writer(&writer);
// Write should succeed and buffer data
let n = w.write(b"test log line\n").unwrap();
assert_eq!(n, 14);
// Drop flushes via suspend — no panic means it works
drop(w);
}
#[test]
fn suspending_writer_empty_does_not_flush() {
let writer = SuspendingWriter;
let w = MakeWriter::make_writer(&writer);
// Drop with empty buffer — should be a no-op
drop(w);
}
}