Skip to main content

faucet_cli/
progress.rs

1//! Inline live progress for `faucet run` (#385, `cli-progress` feature).
2//!
3//! A lightweight alternative to the full-screen `--tui`: one [`indicatif`]
4//! line per active matrix-row invocation showing records in/out, rows/sec,
5//! pages, and elapsed time, updated a few times a second while the run
6//! streams. Numbers come from the same in-process Prometheus recorder the TUI
7//! samples ([`crate::livemetrics`]) — no new measurement plumbing on the hot
8//! path.
9//!
10//! Rendered on **stderr** so a piped stdout stays clean for records; logs also
11//! go to stderr, and indicatif suspends its lines while a log line prints so
12//! the two don't interleave. The live line is only drawn on an interactive
13//! terminal — a non-TTY stdout (CI, pipes) or `--quiet` disables it and the
14//! run falls back to the periodic `tracing` progress logs. `--tui` takes
15//! precedence when both are requested.
16
17use crate::livemetrics::{RowStats, Sampler};
18use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
19use metrics_exporter_prometheus::PrometheusHandle;
20use std::collections::HashMap;
21use std::io::IsTerminal;
22use std::time::{Duration, Instant};
23
24/// Should the inline progress line render? Requires an interactive terminal on
25/// **both** stdout and stderr (progress draws to stderr; the piped-stdout
26/// fallback is keyed on stdout per the issue), and neither `--quiet` nor
27/// `--tui`. On any non-TTY or when suppressed the caller keeps the periodic
28/// log output instead.
29pub fn is_progress_session(quiet: bool, tui: bool) -> bool {
30    !quiet && !tui && std::io::stdout().is_terminal() && std::io::stderr().is_terminal()
31}
32
33/// Max redraws per second — keeps the render throttled well under the hot path.
34const RENDER_HZ: u8 = 10;
35/// Sampling cadence (100ms → 10 Hz, matching `RENDER_HZ`).
36const TICK: Duration = Duration::from_millis(100);
37
38/// Drive the run future while rendering one inline progress line per active
39/// row. Returns the run's result after the lines are finalized. The recorder
40/// `handle` is the same one `livemetrics::setup_observability` installed.
41pub async fn drive<T>(run: impl Future<Output = T>, pipeline: &str, handle: PrometheusHandle) -> T {
42    let multi = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(RENDER_HZ));
43    drive_with(run, pipeline, handle, multi, TICK).await
44}
45
46/// The render/sample loop behind [`drive`], generic over the [`MultiProgress`]
47/// draw target and tick so it runs headless (hidden target) under test.
48async fn drive_with<T>(
49    run: impl Future<Output = T>,
50    pipeline: &str,
51    handle: PrometheusHandle,
52    multi: MultiProgress,
53    tick: Duration,
54) -> T {
55    let mut bars: HashMap<String, ProgressBar> = HashMap::new();
56    let mut sampler = Sampler::new(pipeline);
57    let started = Instant::now();
58    let mut interval = tokio::time::interval(tick);
59    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
60
61    tokio::pin!(run);
62    let result = loop {
63        tokio::select! {
64            biased;
65            result = &mut run => break result,
66            _ = interval.tick() => {
67                handle.run_upkeep();
68                let elapsed = started.elapsed();
69                let model = sampler.observe(&handle.render(), elapsed);
70                for (row_id, row) in &model.rows {
71                    let bar = bars.entry(row_id.clone()).or_insert_with(|| {
72                        let pb = multi.add(ProgressBar::new_spinner());
73                        pb.set_style(spinner_style());
74                        pb.enable_steady_tick(Duration::from_millis(120));
75                        pb
76                    });
77                    bar.set_message(format_row_line(row_id, row, elapsed));
78                }
79            }
80        }
81    };
82
83    // Finalize: one last sample so the closing line reflects the true totals,
84    // then stop every spinner (leaving its final line on screen).
85    handle.run_upkeep();
86    let elapsed = started.elapsed();
87    let model = sampler.observe(&handle.render(), elapsed);
88    for (row_id, bar) in &bars {
89        if let Some(row) = model.rows.get(row_id) {
90            bar.set_message(format_row_line(row_id, row, elapsed));
91        }
92        bar.finish();
93    }
94    result
95}
96
97fn spinner_style() -> ProgressStyle {
98    ProgressStyle::with_template("{spinner:.cyan} {msg}")
99        .unwrap_or_else(|_| ProgressStyle::default_spinner())
100        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✓")
101}
102
103/// Format one row's live line:
104/// `<row>  <src>→<sink>  <in> in / <out> out  <rate>/s  page <p>  <elapsed>`.
105/// Pure so it can be unit-tested without a terminal.
106pub fn format_row_line(row_id: &str, row: &RowStats, elapsed: Duration) -> String {
107    let label = if row_id.is_empty() { "run" } else { row_id };
108    let src = if row.source.is_empty() {
109        "?"
110    } else {
111        &row.source
112    };
113    let sink = if row.sink.is_empty() { "?" } else { &row.sink };
114    let mut line = format!(
115        "{label:<16} {src}→{sink}  {} in / {} out  {}/s  page {}  {}",
116        format_count(row.records_in),
117        format_count(row.records_out),
118        format_rate(row.rate),
119        row.pages,
120        format_elapsed(elapsed),
121    );
122    if row.dlq_records > 0 {
123        line.push_str(&format!("  {} dlq", format_count(row.dlq_records)));
124    }
125    match row.finished {
126        Some(true) => line.push_str("  done"),
127        Some(false) => line.push_str("  FAILED"),
128        None => {}
129    }
130    line
131}
132
133/// Compact human count: `1234` → `1.2k`, `2_500_000` → `2.5M`.
134fn format_count(n: u64) -> String {
135    if n < 1_000 {
136        n.to_string()
137    } else if n < 1_000_000 {
138        format!("{:.1}k", n as f64 / 1_000.0)
139    } else if n < 1_000_000_000 {
140        format!("{:.1}M", n as f64 / 1_000_000.0)
141    } else {
142        format!("{:.1}B", n as f64 / 1_000_000_000.0)
143    }
144}
145
146fn format_rate(r: f64) -> String {
147    if r >= 100.0 {
148        format!("{r:.0}")
149    } else if r >= 10.0 {
150        format!("{r:.1}")
151    } else {
152        format!("{r:.2}")
153    }
154}
155
156fn format_elapsed(d: Duration) -> String {
157    let secs = d.as_secs();
158    let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
159    if h > 0 {
160        format!("{h}h{m:02}m{s:02}s")
161    } else if m > 0 {
162        format!("{m}m{s:02}s")
163    } else {
164        format!("{s}s")
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn quiet_disables_progress() {
174        assert!(!is_progress_session(true, false));
175    }
176
177    #[test]
178    fn tui_takes_precedence_over_progress() {
179        assert!(!is_progress_session(false, true));
180    }
181
182    #[test]
183    fn non_tty_stdout_disables_progress() {
184        // Test harnesses run with a non-TTY stdout, so a plain session must
185        // resolve to `false` here — the CI/pipe fallback contract.
186        assert!(!is_progress_session(false, false) || std::io::stdout().is_terminal());
187    }
188
189    fn row(src: &str, sink: &str, rin: u64, rout: u64, pages: u64, rate: f64) -> RowStats {
190        RowStats {
191            source: src.into(),
192            sink: sink.into(),
193            records_in: rin,
194            records_out: rout,
195            pages,
196            rate,
197            ..Default::default()
198        }
199    }
200
201    #[test]
202    fn format_row_line_renders_all_fields() {
203        let r = row("rest", "jsonl", 1500, 1499, 3, 250.0);
204        let line = format_row_line("orders", &r, Duration::from_secs(65));
205        assert!(line.contains("orders"), "{line}");
206        assert!(line.contains("rest→jsonl"), "{line}");
207        assert!(line.contains("1.5k in"), "{line}");
208        assert!(line.contains("1.5k out"), "{line}");
209        assert!(line.contains("250/s"), "{line}");
210        assert!(line.contains("page 3"), "{line}");
211        assert!(line.contains("1m05s"), "{line}");
212    }
213
214    #[test]
215    fn empty_row_id_falls_back_to_run_label() {
216        let r = row("csv", "stdout", 10, 10, 1, 0.0);
217        let line = format_row_line("", &r, Duration::from_secs(1));
218        assert!(line.starts_with("run"), "{line}");
219    }
220
221    #[test]
222    fn unknown_connectors_render_placeholders() {
223        let r = row("", "", 0, 0, 0, 0.0);
224        let line = format_row_line("x", &r, Duration::ZERO);
225        assert!(line.contains("?→?"), "{line}");
226    }
227
228    #[test]
229    fn finished_and_dlq_annotations() {
230        let mut r = row("rest", "bq", 100, 90, 1, 0.0);
231        r.dlq_records = 10;
232        r.finished = Some(false);
233        let line = format_row_line("r", &r, Duration::from_secs(2));
234        assert!(line.contains("10 dlq"), "{line}");
235        assert!(line.contains("FAILED"), "{line}");
236    }
237
238    #[tokio::test(start_paused = true)]
239    async fn drive_loop_samples_and_finalizes() {
240        // Install the shared recorder and emit a couple of series for pipeline
241        // "p" so the sampler creates a bar and the render loop runs.
242        let handle = crate::livemetrics::install_metrics_recorder(None).expect("recorder");
243        metrics::counter!(
244            "faucet_source_records_total",
245            "pipeline" => "p", "row" => "r", "connector" => "rest"
246        )
247        .increment(5);
248        metrics::counter!(
249            "faucet_sink_records_total",
250            "pipeline" => "p", "row" => "r", "connector" => "jsonl"
251        )
252        .increment(5);
253
254        // Hidden draw target → no terminal needed. The run future outlives a
255        // few ticks so the loop samples at least once, then completes.
256        let multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
257        let out = drive_with(
258            async {
259                tokio::time::sleep(Duration::from_millis(350)).await;
260                "done"
261            },
262            "p",
263            handle,
264            multi,
265            Duration::from_millis(100),
266        )
267        .await;
268        assert_eq!(out, "done");
269    }
270
271    #[tokio::test(start_paused = true)]
272    async fn public_drive_wrapper_runs() {
273        // Exercise the public `drive` entry point (stderr draw target). In the
274        // non-TTY test env indicatif renders nothing; the future still resolves.
275        let handle = crate::livemetrics::install_metrics_recorder(None).expect("recorder");
276        let out = drive(
277            async {
278                tokio::time::sleep(Duration::from_millis(120)).await;
279                99u8
280            },
281            "wrap",
282            handle,
283        )
284        .await;
285        assert_eq!(out, 99);
286    }
287
288    #[test]
289    fn count_and_elapsed_formatting() {
290        assert_eq!(format_count(999), "999");
291        assert_eq!(format_count(1_500), "1.5k");
292        assert_eq!(format_count(2_500_000), "2.5M");
293        assert_eq!(format_elapsed(Duration::from_secs(5)), "5s");
294        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m05s");
295        assert_eq!(format_elapsed(Duration::from_secs(3_665)), "1h01m05s");
296    }
297}