grit_lib/progress.rs
1//! Progress reporting and cancellation across the library/CLI boundary.
2//!
3//! Library operations never touch the terminal. Long-running work reports
4//! progress through a [`ProgressSink`] supplied by the caller and checks a
5//! [`Cancel`] signal at loop boundaries. The CLI provides a concrete sink that
6//! draws to stderr (gated on `isatty`) and decides colour, redraw rate, and
7//! whether a pager is attached; the library makes none of those decisions.
8//!
9//! Tests, `grit-simple`, and any call site that does not want output use the
10//! no-op [`NullProgress`] / [`NeverCancel`].
11
12/// A sink for progress updates emitted by a library operation.
13///
14/// Every method has a no-op default, so a sink implements only what it needs.
15/// The library calls these; it never decides whether output is a tty, what
16/// colour to use, or how often to redraw — that is the CLI's responsibility.
17pub trait ProgressSink {
18 /// Begin a new progress phase labelled `label`, optionally with a known
19 /// total count of units (e.g. objects). `None` means the total is unknown.
20 fn start(&mut self, label: &str, total: Option<u64>) {
21 let _ = (label, total);
22 }
23
24 /// Advance the current phase by `units`.
25 fn inc(&mut self, units: u64) {
26 let _ = units;
27 }
28
29 /// Set the absolute progress of the current phase to `current` units.
30 fn set(&mut self, current: u64) {
31 let _ = current;
32 }
33
34 /// Emit an out-of-band human-readable message (e.g. a remote sideband line
35 /// or `"Trying merge strategy ..."`).
36 fn message(&mut self, msg: &str) {
37 let _ = msg;
38 }
39
40 /// Finish the current phase.
41 fn finish(&mut self) {}
42}
43
44/// Forwarding impl so a `&mut P` can be threaded into sub-operations that also
45/// take `impl ProgressSink` without re-borrowing gymnastics at every call site.
46impl<T: ProgressSink + ?Sized> ProgressSink for &mut T {
47 fn start(&mut self, label: &str, total: Option<u64>) {
48 (**self).start(label, total);
49 }
50 fn inc(&mut self, units: u64) {
51 (**self).inc(units);
52 }
53 fn set(&mut self, current: u64) {
54 (**self).set(current);
55 }
56 fn message(&mut self, msg: &str) {
57 (**self).message(msg);
58 }
59 fn finish(&mut self) {
60 (**self).finish();
61 }
62}
63
64/// A [`ProgressSink`] that discards every update. The default for tests,
65/// `grit-simple`, and any call site that does not want progress output.
66#[derive(Debug, Default, Clone, Copy)]
67pub struct NullProgress;
68
69impl ProgressSink for NullProgress {}
70
71/// A cancellation signal checked by long-running library operations at loop
72/// boundaries. Implementations must be cheap to query.
73pub trait Cancel {
74 /// Returns `true` once the operation should stop as soon as possible.
75 fn is_cancelled(&self) -> bool;
76}
77
78/// A [`Cancel`] that never signals cancellation. The default for call sites
79/// that do not support interruption.
80#[derive(Debug, Default, Clone, Copy)]
81pub struct NeverCancel;
82
83impl Cancel for NeverCancel {
84 fn is_cancelled(&self) -> bool {
85 false
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 /// A sink that records what it was told, to prove the trait + forwarding
94 /// impl route calls correctly.
95 #[derive(Default)]
96 struct Recorder {
97 started: Vec<(String, Option<u64>)>,
98 incs: u64,
99 messages: Vec<String>,
100 finished: usize,
101 }
102
103 impl ProgressSink for Recorder {
104 fn start(&mut self, label: &str, total: Option<u64>) {
105 self.started.push((label.to_string(), total));
106 }
107 fn inc(&mut self, units: u64) {
108 self.incs += units;
109 }
110 fn message(&mut self, msg: &str) {
111 self.messages.push(msg.to_string());
112 }
113 fn finish(&mut self) {
114 self.finished += 1;
115 }
116 }
117
118 fn drive(sink: &mut impl ProgressSink) {
119 sink.start("objects", Some(3));
120 sink.inc(1);
121 sink.inc(2);
122 sink.message("done");
123 sink.finish();
124 }
125
126 #[test]
127 fn null_progress_is_inert() {
128 // Must compile and run without panicking; nothing to assert.
129 drive(&mut NullProgress);
130 }
131
132 #[test]
133 fn forwarding_impl_routes_to_inner() {
134 let mut rec = Recorder::default();
135 // Pass `&mut Recorder` where `impl ProgressSink` is expected, exercising
136 // the `&mut T` forwarding impl.
137 drive(&mut &mut rec);
138 assert_eq!(rec.started, vec![("objects".to_string(), Some(3))]);
139 assert_eq!(rec.incs, 3);
140 assert_eq!(rec.messages, vec!["done".to_string()]);
141 assert_eq!(rec.finished, 1);
142 }
143
144 #[test]
145 fn never_cancel_never_cancels() {
146 assert!(!NeverCancel.is_cancelled());
147 }
148}