Skip to main content

consortium_cli/
display.rs

1//! Shared output formatting for consortium CLI tools.
2//!
3//! Provides nh-inspired progress reporting and output formatting.
4
5use std::collections::HashMap;
6use std::io::{self, Write};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use console::style;
12use indicatif::{ProgressBar, ProgressStyle};
13
14use consortium::worker::EventHandler;
15
16// ── Progress tracking ───────────────────────────────────────────────────────
17
18/// Tracks completed/failed node counts. Shared between ProgressHandler and caller.
19#[derive(Clone)]
20pub struct ProgressState {
21    completed: Arc<AtomicUsize>,
22    failed: Arc<AtomicUsize>,
23    total: usize,
24}
25
26impl ProgressState {
27    pub fn new(total: usize) -> Self {
28        Self {
29            completed: Arc::new(AtomicUsize::new(0)),
30            failed: Arc::new(AtomicUsize::new(0)),
31            total,
32        }
33    }
34
35    pub fn completed(&self) -> usize {
36        self.completed.load(Ordering::Relaxed)
37    }
38
39    pub fn failed(&self) -> usize {
40        self.failed.load(Ordering::Relaxed)
41    }
42
43    pub fn total(&self) -> usize {
44        self.total
45    }
46}
47
48/// EventHandler that drives an indicatif ProgressBar on each node completion.
49///
50/// Pass as `user_handler` to `task.schedule()`. The TaskGatheringHandler
51/// chains `on_close`/`on_timeout` to us — no core changes needed.
52///
53/// ProgressBar is internally Arc'd, so updates from the poll thread are
54/// immediately visible to the steady-tick render thread.
55pub struct ProgressHandler {
56    state: ProgressState,
57    bar: ProgressBar,
58}
59
60impl ProgressHandler {
61    pub fn new(state: ProgressState, bar: ProgressBar) -> Self {
62        Self { state, bar }
63    }
64}
65
66impl EventHandler for ProgressHandler {
67    fn on_close(&mut self, _node: &str, rc: i32) {
68        if rc != 0 {
69            self.state.failed.fetch_add(1, Ordering::Relaxed);
70        }
71        let done = self.state.completed.fetch_add(1, Ordering::Relaxed) + 1;
72        self.bar.set_position(done as u64);
73
74        let failed = self.state.failed();
75        if failed > 0 {
76            self.bar
77                .set_message(format!("({} failed)", style(failed).red()));
78        }
79    }
80
81    fn on_timeout(&mut self, _node: &str) {
82        self.state.failed.fetch_add(1, Ordering::Relaxed);
83        let done = self.state.completed.fetch_add(1, Ordering::Relaxed) + 1;
84        self.bar.set_position(done as u64);
85        self.bar
86            .set_message(format!("({} failed)", style(self.state.failed()).red()));
87    }
88}
89
90/// Create an nh-style progress bar + state for cluster operations.
91///
92/// Returns (ProgressBar, ProgressState, ProgressHandler). Pass the handler
93/// to `task.schedule(..., Some(Box::new(handler)), ...)`.
94pub fn create_progress(total: usize) -> (ProgressBar, ProgressState, ProgressHandler) {
95    let state = ProgressState::new(total);
96    let bar = ProgressBar::new(total as u64);
97
98    bar.set_style(
99        ProgressStyle::with_template(
100            "{spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} nodes {msg} ({elapsed})",
101        )
102        .unwrap()
103        .progress_chars("━╸─"),
104    );
105    bar.enable_steady_tick(Duration::from_millis(80));
106
107    let handler = ProgressHandler::new(state.clone(), bar.clone());
108    (bar, state, handler)
109}
110
111/// Finish the progress bar with a summary line.
112pub fn finish_progress(bar: &ProgressBar, state: &ProgressState, elapsed: Duration) {
113    let done = state.completed();
114    let failed = state.failed();
115    let ok = done - failed;
116    let total = state.total();
117
118    bar.finish_and_clear();
119
120    let elapsed_str = format!("{:.1}s", elapsed.as_secs_f64());
121
122    if failed == 0 {
123        eprintln!(
124            "{} {}/{} nodes completed in {}",
125            style("✓").green().bold(),
126            ok,
127            total,
128            style(elapsed_str).dim()
129        );
130    } else {
131        eprintln!(
132            "{} {}/{} ok, {} failed in {}",
133            style("✗").red().bold(),
134            ok,
135            total,
136            style(failed).red().bold(),
137            style(elapsed_str).dim()
138        );
139    }
140}
141
142// ── Output formatting ───────────────────────────────────────────────────────
143
144/// Print gathered output with a node header.
145///
146/// Mirrors `Display.format_header()` from the Python oracle: the node count
147/// is appended as ` (N)` only when more than one node is gathered
148/// (`node_count` at standard verbosity).
149///
150/// ```text
151/// ---------------
152/// node[1-3] (3)
153/// ---------------
154/// hello world
155/// ```
156pub fn print_gathered_header(nodes: &str, count: usize, out: &mut impl Write) -> io::Result<()> {
157    let sep = "-".repeat(15);
158    writeln!(out, "{sep}")?;
159    if count > 1 {
160        writeln!(out, "{nodes} ({count})")?;
161    } else {
162        writeln!(out, "{nodes}")?;
163    }
164    writeln!(out, "{sep}")?;
165    Ok(())
166}
167
168/// Print a single line with node prefix: `node1: output`
169pub fn print_line_with_label(node: &str, line: &str, out: &mut impl Write) -> io::Result<()> {
170    writeln!(out, "{node}: {line}")
171}
172
173// ── Streaming line output (GH#528/GH#597 port) ──────────────────────────────
174
175/// Streaming per-line output handler: prints each completed output line as
176/// soon as it arrives instead of buffering everything until command exit.
177///
178/// Ports the upstream line-buffering fix (ClusterShell commits 65e5433 /
179/// GH#528/GH#597): in Python, `Display` reconfigures `sys.stdout` /
180/// `sys.stderr` with `line_buffering=True` so that piped consumers observe
181/// lines in real time. Rust's `io::Stdout` is already a `LineWriter`, and
182/// this handler additionally flushes after every completed line, giving the
183/// same observable behavior: lines arrive as they are produced, not in one
184/// block at process exit.
185///
186/// Partial lines are buffered per node and flushed on `on_close`, so no
187/// output is lost even when a node's final line lacks a trailing newline.
188/// Lines print to stdout with the usual `node: ` label unless disabled.
189///
190/// Note: the core `ExecWorker` currently reports raw pipe fds in `on_read`
191/// (never normalized to 1/2), so stderr chunks cannot be told apart from
192/// stdout here — consistent with `TaskGatheringHandler`, which also folds
193/// them into the stdout buffers. Once the core normalizes fds, a `fd == 2`
194/// branch can route stderr lines to stderr like Python's `print_line_error`.
195///
196/// An optional inner handler (e.g. [`ProgressHandler`]) is chained after
197/// every callback.
198pub struct LineStreamHandler {
199    label: bool,
200    partial: HashMap<String, Vec<u8>>,
201    inner: Option<Box<dyn EventHandler>>,
202}
203
204impl LineStreamHandler {
205    /// Create a handler; `label` controls the `node: ` prefix on lines.
206    pub fn new(label: bool) -> Self {
207        Self {
208            label,
209            partial: HashMap::new(),
210            inner: None,
211        }
212    }
213
214    /// Chain another handler (called after this one on every event).
215    pub fn with_inner(mut self, inner: Box<dyn EventHandler>) -> Self {
216        self.inner = Some(inner);
217        self
218    }
219
220    /// Print one completed line to stdout and flush.
221    fn emit_line(&self, node: &str, bytes: &[u8]) {
222        let text = String::from_utf8_lossy(bytes);
223        let mut out = io::stdout().lock();
224        if self.label {
225            let _ = writeln!(out, "{node}: {text}");
226        } else {
227            let _ = writeln!(out, "{text}");
228        }
229        // Line-buffering semantics: make each line visible to piped
230        // consumers immediately (Stdout is a LineWriter; flush anyway).
231        let _ = out.flush();
232    }
233
234    /// Append a chunk to the node's partial buffer and emit completed lines.
235    fn feed(&self, node: &str, buf: &mut Vec<u8>, chunk: &[u8]) {
236        buf.extend_from_slice(chunk);
237        while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
238            let line: Vec<u8> = buf.drain(..pos).collect();
239            let _ = buf.drain(..1); // drop the newline
240            self.emit_line(node, &line);
241        }
242    }
243
244    /// Flush any remaining partial line for a node (called on close).
245    fn flush_partial(&mut self, node: &str) {
246        if let Some(buf) = self.partial.remove(node) {
247            if !buf.is_empty() {
248                self.emit_line(node, &buf);
249            }
250        }
251    }
252}
253
254impl EventHandler for LineStreamHandler {
255    fn on_start(&mut self, worker: &dyn consortium::worker::Worker) {
256        if let Some(ref mut h) = self.inner {
257            h.on_start(worker);
258        }
259    }
260
261    fn on_read(&mut self, node: &str, fd: std::os::unix::io::RawFd, msg: &[u8]) {
262        let _ = fd; // see struct note: raw pipe fds, not normalized to 1/2
263        let mut buf = self.partial.remove(node).unwrap_or_default();
264        self.feed(node, &mut buf, msg);
265        self.partial.insert(node.to_string(), buf);
266        if let Some(ref mut h) = self.inner {
267            h.on_read(node, fd, msg);
268        }
269    }
270
271    fn on_close(&mut self, node: &str, rc: i32) {
272        self.flush_partial(node);
273        if let Some(ref mut h) = self.inner {
274            h.on_close(node, rc);
275        }
276    }
277
278    fn on_timeout(&mut self, node: &str) {
279        self.flush_partial(node);
280        if let Some(ref mut h) = self.inner {
281            h.on_timeout(node);
282        }
283    }
284
285    fn on_error(&mut self, node: &str, error: &consortium::worker::WorkerError) {
286        if let Some(ref mut h) = self.inner {
287            h.on_error(node, error);
288        }
289    }
290}