Skip to main content

thoughts_tool/git/
progress.rs

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