Skip to main content

thoughts_tool/git/
progress.rs

1use 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/// A simple inline progress reporter for gitoxide operations.
18/// Shows progress on a single line with carriage return updates.
19#[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        // Throttle updates
55        {
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        // Return the shared counter so external increments affect our state
104        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        // We keep our own name, ignore updates
123    }
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        // Ignore messages for now
135    }
136}
137
138impl NestedProgress for InlineProgress {
139    type SubProgress = Self;
140
141    fn add_child(&mut self, name: impl Into<String>) -> Self::SubProgress {
142        // Finish current line before starting child
143        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        // Ensure we print a newline when done
165        if !self.state.finished.swap(true, Ordering::Relaxed) {
166            // Only print newline if we actually drew something
167            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}