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

41
Cargo.lock generated
View File

@@ -76,12 +76,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -960,7 +954,6 @@ dependencies = [
"portable-atomic",
"unicode-width",
"unit-prefix",
"vt100",
"web-time",
]
@@ -1115,7 +1108,6 @@ dependencies = [
"thiserror",
"tokio",
"tracing",
"tracing-indicatif",
"tracing-subscriber",
"url",
"urlencoding",
@@ -2032,18 +2024,6 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-indicatif"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ef6990e0438749f0080573248e96631171a0b5ddfddde119aa5ba8c3a9c47e"
dependencies = [
"indicatif",
"tracing",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@@ -2174,27 +2154,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "want"
version = "0.3.1"

View File

@@ -53,7 +53,6 @@ libc = "0.2"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-indicatif = "0.3"
[dev-dependencies]
tempfile = "3"

View File

@@ -40,7 +40,7 @@ fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressB
if robot_mode {
return ProgressBar::hidden();
}
let pb = ProgressBar::new_spinner();
let pb = crate::cli::progress::multi().add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.blue} {msg}")

View File

@@ -1,6 +1,7 @@
//! CLI module with clap command definitions.
pub mod commands;
pub mod progress;
use clap::{Parser, Subcommand};
use std::io::IsTerminal;

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);
}
}

View File

@@ -41,21 +41,20 @@ async fn main() {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// Initialize logging with indicatif support for clean progress bar output
let indicatif_layer = tracing_indicatif::IndicatifLayer::new();
// Initialize logging with progress-bar-aware writer.
// SuspendingWriter suspends the shared MultiProgress before each log line,
// preventing log output from interleaving with progress bar animations.
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(indicatif_layer.get_stderr_writer()),
.with_writer(lore::cli::progress::SuspendingWriter),
)
.with(
EnvFilter::from_default_env()
.add_directive("lore=info".parse().unwrap())
.add_directive("warn".parse().unwrap()),
)
.with(indicatif_layer)
.init();
let cli = Cli::parse();