thoughts_tool/git/
progress.rs

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