Skip to main content

heddle_cli_render/cli/
progress_render.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Terminal renderer for the generic [`Progress`](objects::Progress) substrate.
3//!
4//! This is the *only* place that turns a [`ProgressSnapshot`] into bytes on a
5//! terminal. Domain crates drive a `Progress` handle; the CLI installs a
6//! [`TerminalSink`] via the JSON-guarded factory ([`progress_for`]) so progress
7//! is written to stderr and never leaks into result-only stdout (#550).
8//!
9//! # Throttling lives here
10//!
11//! `Progress::inc` calls `render` on every active tick; the sink decides
12//! whether to actually repaint. We coalesce to at most one redraw per
13//! [`COMMIT_TICK_INTERVAL`] completed units, but always repaint on a phase
14//! change so stage transitions show immediately. This mirrors the cadence of
15//! the bespoke import progress line this sink replaces.
16
17use std::{
18    io::{self, IsTerminal, Write},
19    sync::Mutex,
20};
21
22use objects::{Progress, ProgressSnapshot, Sink};
23use repo::Repository;
24
25use crate::cli::style;
26use heddle_cli_args::{Cli, should_output_json};
27
28/// Redraw the live line at most once per this many completed units, so a large
29/// operation doesn't spend its time flushing the terminal. Matches the historic
30/// import cadence.
31pub const COMMIT_TICK_INTERVAL: usize = 64;
32
33pub fn format_transfer_bytes(bytes: u64) -> String {
34    const KIB: u64 = 1024;
35    const MIB: u64 = KIB * 1024;
36    const GIB: u64 = MIB * 1024;
37    if bytes < KIB {
38        format!("{bytes} B")
39    } else if bytes < MIB {
40        format!("{:.1} KiB", bytes as f64 / KIB as f64)
41    } else if bytes < GIB {
42        format!("{:.1} MiB", bytes as f64 / MIB as f64)
43    } else {
44        format!("{:.1} GiB", bytes as f64 / GIB as f64)
45    }
46}
47
48/// Build a [`Progress`] handle for a command, applying the single JSON guard
49/// (#550) exactly once at construction: JSON output → a null handle that
50/// renders nothing; otherwise → a [`TerminalSink`]. The guard is never checked
51/// again per update.
52pub fn progress_for(cli: &Cli, repo: &Repository) -> Progress {
53    if should_output_json(cli, Some(repo.config())) {
54        Progress::null()
55    } else {
56        Progress::with_sink(Box::new(TerminalSink::new()))
57    }
58}
59
60/// A `Sink` that paints a single, self-overwriting progress line.
61///
62/// - On a TTY: `\r`-carriage-return redraw of one dim line, throttled to one
63///   repaint per [`COMMIT_TICK_INTERVAL`] units (plus forced repaint on phase
64///   change and on the first/last unit).
65/// - Off a TTY (piped human output): one dim line per throttled tick, no
66///   control codes.
67///
68/// The sink is `Sync`; its small amount of interior state (last painted phase +
69/// tick bookkeeping) lives behind a `Mutex`. Renders are cheap and infrequent
70/// relative to `inc`, so the lock is not on any hot path.
71pub struct TerminalSink {
72    state: Mutex<RenderState>,
73}
74
75#[derive(Default)]
76struct RenderState {
77    /// Phase last painted; a change forces a repaint regardless of throttle.
78    last_phase: Option<String>,
79    /// Whether anything has been painted yet (drives the leading redraw and the
80    /// `finish` clear).
81    painted: bool,
82}
83
84impl TerminalSink {
85    pub fn new() -> Self {
86        Self {
87            state: Mutex::new(RenderState::default()),
88        }
89    }
90
91    fn lock(&self) -> std::sync::MutexGuard<'_, RenderState> {
92        self.state.lock().unwrap_or_else(|p| p.into_inner())
93    }
94
95    /// Decide whether this snapshot should repaint. Always repaint on a phase
96    /// change or the first paint; otherwise throttle on the completed count.
97    fn should_repaint(state: &RenderState, snap: &ProgressSnapshot) -> bool {
98        let phase_changed = state.last_phase.as_deref() != Some(snap.phase.as_str());
99        phase_changed
100            || snap.done == 0
101            || (snap.total != 0 && snap.done == snap.total)
102            || snap.done.is_multiple_of(COMMIT_TICK_INTERVAL)
103    }
104
105    /// Format the line to paint: the phase label, plus a live `(done/total,
106    /// pct%)` suffix when the snapshot carries a real count.
107    ///
108    /// A consumer that pre-formats its own counts into the phase string (the
109    /// import flow) never advances `done`, so `done == 0` and no suffix is
110    /// appended — its lines paint verbatim. A count-driven seam (tree
111    /// materialization) leaves the phase a bare label and increments `done`, so
112    /// the suffix supplies the live count. This is the single rule that lets one
113    /// renderer serve both styles without double-counting.
114    fn format_line(snap: &ProgressSnapshot) -> String {
115        if snap.done > 0 && snap.total > 0 {
116            let pct = snap.done.saturating_mul(100) / snap.total;
117            format!("{} ({}/{}, {}%)", snap.phase, snap.done, snap.total, pct)
118        } else {
119            snap.phase.clone()
120        }
121    }
122
123    fn paint(line: &str) {
124        if io::stderr().is_terminal() {
125            // `\r` to column 0, `\x1b[K` clears to end of line so a shorter line
126            // doesn't leave stale trailing characters.
127            eprint!("\r{}\x1b[K", style::dim(line));
128            io::stderr().flush().ok();
129        } else {
130            eprintln!("{}", style::dim(line));
131        }
132    }
133}
134
135impl Default for TerminalSink {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl Sink for TerminalSink {
142    fn render(&self, snap: ProgressSnapshot) {
143        // The phase label is the human line; the counters drive throttling and
144        // (for count-driven seams) the live `(done/total)` suffix. An empty
145        // phase is a no-op.
146        if snap.phase.is_empty() {
147            return;
148        }
149        let mut state = self.lock();
150        if !Self::should_repaint(&state, &snap) {
151            return;
152        }
153        Self::paint(&Self::format_line(&snap));
154        state.last_phase = Some(snap.phase);
155        state.painted = true;
156    }
157}
158
159/// Clear an active TTY progress line before rendering command output.
160#[cfg(feature = "client")]
161pub fn clear_line(progress: &Progress) {
162    if !progress.is_active() {
163        return;
164    }
165    if io::stderr().is_terminal() {
166        eprint!("\r\x1b[K");
167        io::stderr().flush().ok();
168    }
169}
170
171/// Paint a terminal "done" line for a finished [`Progress`], clearing the live
172/// line first on a TTY. No-op for a null (inactive) handle. Used by consumers
173/// that want an explicit completion marker (e.g. import's `[done]` line).
174pub fn finish_line(progress: &Progress, message: &str) {
175    if !progress.is_active() {
176        return;
177    }
178    if io::stderr().is_terminal() {
179        eprintln!("\r\x1b[K{}", style::accent(message));
180        io::stderr().flush().ok();
181    } else {
182        eprintln!("{}", style::accent(message));
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn repaints_on_phase_change_regardless_of_throttle() {
192        let mut state = RenderState {
193            last_phase: Some("old".into()),
194            painted: true,
195        };
196        let snap = ProgressSnapshot {
197            done: 7, // not a throttle boundary
198            total: 100,
199            phase: "new".into(),
200        };
201        assert!(TerminalSink::should_repaint(&state, &snap));
202        // Same phase, off-boundary count -> no repaint.
203        state.last_phase = Some("new".into());
204        assert!(!TerminalSink::should_repaint(&state, &snap));
205    }
206
207    #[test]
208    fn count_driven_snapshot_gets_a_live_suffix() {
209        // A materialize-style seam leaves the phase a bare label and advances
210        // `done`; the renderer supplies the live `(done/total, pct%)` suffix.
211        let snap = ProgressSnapshot {
212            done: 32,
213            total: 128,
214            phase: "checking out files".into(),
215        };
216        assert_eq!(
217            TerminalSink::format_line(&snap),
218            "checking out files (32/128, 25%)"
219        );
220    }
221
222    #[test]
223    fn preformatted_line_without_a_count_paints_verbatim() {
224        // The import flow pre-formats its own counts into the phase and never
225        // advances `done` (stays 0), so no suffix is appended and the line is
226        // painted exactly as given — no double-counting.
227        let snap = ProgressSnapshot {
228            done: 0,
229            total: 3,
230            phase: "[2/3] importing commits... 64/128 inspected (50%)".into(),
231        };
232        assert_eq!(
233            TerminalSink::format_line(&snap),
234            "[2/3] importing commits... 64/128 inspected (50%)"
235        );
236        // A count with an unknown total (total == 0) also gets no suffix.
237        let counting = ProgressSnapshot {
238            done: 500,
239            total: 0,
240            phase: "scanning".into(),
241        };
242        assert_eq!(TerminalSink::format_line(&counting), "scanning");
243    }
244
245    #[test]
246    fn repaints_on_throttle_boundary_and_edges() {
247        let state = RenderState {
248            last_phase: Some("p".into()),
249            painted: true,
250        };
251        for (done, total, want) in [
252            (0, 100, true),
253            (64, 100, true),
254            (100, 100, true),
255            (63, 100, false),
256        ] {
257            let snap = ProgressSnapshot {
258                done,
259                total,
260                phase: "p".into(),
261            };
262            assert_eq!(
263                TerminalSink::should_repaint(&state, &snap),
264                want,
265                "done={done} total={total}"
266            );
267        }
268    }
269}