How to add a progress bar to a CLI
Short answer: Use indicatif’s ProgressBar. It draws to stderr — the stream the previous step reserves for progress — and hides itself when stderr is not a terminal, so mycli > out.txt keeps the results file clean.
From a fresh project — cargo new progress-demo, then cargo add indicatif — Cargo.toml needs one line:
[dependencies]indicatif = "0.18.6"A bar over known work
Section titled “A bar over known work”use std::{thread::sleep, time::Duration};
use indicatif::{ProgressBar, ProgressStyle};
fn main() { let bar = ProgressBar::new(4); bar.set_style(ProgressStyle::with_template("{bar:40} {pos}/{len}").unwrap()); for _ in 0..4 { sleep(Duration::from_millis(250)); bar.inc(1); } bar.finish_and_clear(); println!("done");}ProgressBar::new(4) is a bar with a known length, inc(1) advances it once per item, and finish_and_clear() erases the line when the work is done. The default template is {wide_bar} {pos}/{len}, which stretches to the terminal’s width; {bar:40} is the same bar at a fixed forty cells, so what follows looks the same on any terminal. finish() on its own leaves the cursor at the end of the bar, and the next println! continues on that line — which is why the ending here is finish_and_clear().
In a terminal the bar animates on one line. python3 opens a pseudo-terminal, as the previous step showed, which is what makes the bar visible here:
$ python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1/4████████████████████░░░░░░░░░░░░░░░░░░░░ 2/4██████████████████████████████░░░░░░░░░░ 3/4████████████████████████████████████████ 4/4doneFrames are separated by a carriage return and a clear-line escape, which lets a terminal overwrite one line in place; tr '\r' '\n' puts each frame on its own line so they can be shown at all. The blank line after done is the program’s last newline arriving as \r\n, which tr turns back into two. The frame count depends on timing — indicatif redraws at most twenty times a second — but the last frame stays on screen.
stderr versus stdout
Section titled “stderr versus stdout”The bar never touches stdout: ProgressDrawTarget::stderr() is the default. Pipe stdout and the bar keeps drawing — the mycli | tee log.txt case from the previous step, where the user watches the terminal while the file fills:
$ python3 -c 'import pty; pty.spawn(["sh","-c","cargo run -q > out.txt"])' | tr '\r' '\n'██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1/4████████████████████░░░░░░░░░░░░░░░░░░░░ 2/4██████████████████████████████░░░░░░░░░░ 3/4████████████████████████████████████████ 4/4out.txt holds the result and nothing else. The empty line at the end of the capture is the bar’s line after finish_and_clear() erased it. Point stderr at a file instead:
$ python3 -c 'import pty; pty.spawn(["sh","-c","cargo run -q 2>frames.txt"])' | tr '\r' '\n'done
$ wc -c < frames.txt 0The bar is gone from the terminal and the file is empty: indicatif hides the bar rather than writing escape codes into something that is not a terminal. Both streams piped, or TERM=dumb on a real terminal, gets the same restraint:
$ cargo run -q 2>&1 | catdone$ TERM=dumb python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'doneMessages over the bar
Section titled “Messages over the bar”use std::{thread::sleep, time::Duration};
use indicatif::{ProgressBar, ProgressStyle};
fn main() { let bar = ProgressBar::new(2); bar.set_style(ProgressStyle::with_template("{bar:40} {pos}/{len}").unwrap()); sleep(Duration::from_millis(250)); bar.inc(1); bar.println("halfway"); sleep(Duration::from_millis(250)); bar.inc(1); bar.finish_and_clear(); eprintln!("all done");}bar.println() writes the message padded to the terminal’s width, and the next draw wraps onto the line below — that wrap is what puts the message above the bar. In the capture the two share a line because the stream holds no newline between them:
$ python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'████████████████████░░░░░░░░░░░░░░░░░░░░ 1/2halfway ████████████████████░░░░░░░░░░░░░░░░░░░░ 1/2████████████████████████████████████████ 2/2all doneWith the bar hidden the message is not printed at all — the piped run shows all done and nothing else:
$ cargo run -q 2>&1 | catall doneMessages that must always appear belong on eprintln!; bar.suspend(|| eprintln!(…)) also redraws the bar afterwards.
When the length is unknown
Section titled “When the length is unknown”use std::{thread::sleep, time::Duration};
use indicatif::ProgressBar;
fn main() { let bar = ProgressBar::new_spinner(); bar.enable_steady_tick(Duration::from_millis(120)); bar.set_message("waiting"); sleep(Duration::from_secs(1)); bar.finish_with_message("ready");}new_spinner() trades the bar for a spinning character, enable_steady_tick advances it on a timer, and finish_with_message leaves the message where the spinner was:
$ python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'⠁ waiting⠁ waiting⠉ waiting⠙ waiting⠚ waiting⠒ waiting⠂ waiting⠂ waiting⠒ waiting⠲ waiting readyWhich character the first frame shows depends on when the ticker thread first runs; the count and the last line do not. In a pipe there is nothing at all, not even ready — a hidden draw target has nothing to draw:
$ cargo run -q 2>&1 | cat- How to detect whether output is a terminal — the previous step, and the stream every bar on this page is drawn on
- Showing progress for slow requests — the same spinner in a real CLI, appearing only when a request is slow
- Back to Building a command-line tool
The next step in this chapter reads a config file, so settings a CLI keeps retyping can live in one place.