thoughts_tool/git/
progress.rs1use std::fmt::Write as _;
2use std::io::Write;
3use std::io::{self};
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6use std::sync::atomic::AtomicUsize;
7use std::sync::atomic::Ordering;
8use std::time::Duration;
9use std::time::Instant;
10
11use gix::progress::Count;
12use gix::progress::NestedProgress;
13use gix::progress::Progress;
14use gix::progress::StepShared;
15use gix::progress::Unit;
16
17#[derive(Clone)]
20pub struct InlineProgress {
21 name: String,
22 state: Arc<State>,
23}
24
25struct State {
26 last_draw: std::sync::Mutex<Option<Instant>>,
27 current: StepShared,
28 max: AtomicUsize,
29 has_max: AtomicBool,
30 finished: AtomicBool,
31}
32
33impl InlineProgress {
34 pub fn new(name: impl Into<String>) -> Self {
35 Self {
36 name: name.into(),
37 state: Arc::new(State {
38 last_draw: std::sync::Mutex::new(None),
39 current: Arc::new(AtomicUsize::new(0)),
40 max: AtomicUsize::new(0),
41 has_max: AtomicBool::new(false),
42 finished: AtomicBool::new(false),
43 }),
44 }
45 }
46
47 #[expect(
48 clippy::unwrap_used,
49 reason = "Mutex poisoning indicates a panic elsewhere; propagating is correct"
50 )]
51 fn draw(&self) {
52 let now = Instant::now();
53
54 {
56 let mut last = self.state.last_draw.lock().unwrap();
57 if let Some(last_time) = *last
58 && now.duration_since(last_time) < Duration::from_millis(50)
59 && self.state.has_max.load(Ordering::Relaxed)
60 {
61 return;
62 }
63 *last = Some(now);
64 }
65
66 let current = self.state.current.load(Ordering::Relaxed);
67 let has_max = self.state.has_max.load(Ordering::Relaxed);
68 let max = self.state.max.load(Ordering::Relaxed);
69
70 let mut line = String::new();
71 line.push_str(" ");
72 line.push_str(&self.name);
73 line.push_str(": ");
74
75 if has_max && max > 0 {
76 let pct = (current as f32 / max as f32) * 100.0;
77 let _ = write!(line, "{current}/{max} ({pct:.1}%)");
78 } else {
79 let _ = write!(line, "{current}");
80 }
81
82 print!("\r{line}");
83 let _ = io::stdout().flush();
84 }
85}
86
87impl Count for InlineProgress {
88 fn set(&self, step: usize) {
89 self.state.current.store(step, Ordering::Relaxed);
90 self.draw();
91 }
92
93 fn step(&self) -> usize {
94 self.state.current.load(Ordering::Relaxed)
95 }
96
97 fn inc_by(&self, step: usize) {
98 self.state.current.fetch_add(step, Ordering::Relaxed);
99 self.draw();
100 }
101
102 fn counter(&self) -> gix::progress::StepShared {
103 Arc::clone(&self.state.current)
105 }
106}
107
108impl Progress for InlineProgress {
109 fn init(&mut self, max: Option<usize>, _unit: Option<Unit>) {
110 if let Some(m) = max {
111 self.state.max.store(m, Ordering::Relaxed);
112 self.state.has_max.store(true, Ordering::Relaxed);
113 } else {
114 self.state.has_max.store(false, Ordering::Relaxed);
115 }
116 self.state.current.store(0, Ordering::Relaxed);
117 self.state.finished.store(false, Ordering::Relaxed);
118 self.draw();
119 }
120
121 fn set_name(&mut self, _name: String) {
122 }
124
125 fn name(&self) -> Option<String> {
126 Some(self.name.clone())
127 }
128
129 fn id(&self) -> gix::progress::Id {
130 [0u8; 4]
131 }
132
133 fn message(&self, _level: gix::progress::MessageLevel, _message: String) {
134 }
136}
137
138impl NestedProgress for InlineProgress {
139 type SubProgress = Self;
140
141 fn add_child(&mut self, name: impl Into<String>) -> Self::SubProgress {
142 if !self.state.finished.load(Ordering::Relaxed) {
144 println!();
145 }
146 Self::new(name)
147 }
148
149 fn add_child_with_id(
150 &mut self,
151 name: impl Into<String>,
152 _id: gix::progress::Id,
153 ) -> Self::SubProgress {
154 self.add_child(name)
155 }
156}
157
158impl Drop for InlineProgress {
159 #[expect(
160 clippy::unwrap_used,
161 reason = "Mutex poisoning indicates a panic elsewhere; propagating is correct"
162 )]
163 fn drop(&mut self) {
164 if !self.state.finished.swap(true, Ordering::Relaxed) {
166 if self.state.last_draw.lock().unwrap().is_some() {
168 println!();
169 }
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn init_and_inc() {
180 let mut p = InlineProgress::new("test");
181 p.init(Some(100), None);
182 p.inc_by(1);
183 p.inc_by(9);
184 p.set(25);
185 }
186
187 #[test]
188 fn nested_children() {
189 let mut p = InlineProgress::new("root");
190 let mut c1 = p.add_child("child-1");
191 c1.init(Some(10), None);
192 c1.inc_by(3);
193 }
194
195 #[test]
196 fn no_max_progress() {
197 let mut p = InlineProgress::new("bytes");
198 p.init(None, None);
199 p.inc_by(100);
200 p.inc_by(200);
201 }
202
203 #[test]
204 fn counter_is_shared() {
205 use std::sync::atomic::Ordering;
206 let p = InlineProgress::new("t");
207 let c = p.counter();
208 c.fetch_add(5, Ordering::Relaxed);
209 assert_eq!(p.step(), 5);
210 }
211}