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}