Skip to main content

lean_ctx/core/
index_progress.rs

1//! Shared progress counters for index builds (BM25, semantic, graph).
2//! Build code reports here; CLI / status_json read snapshots. Decouples
3//! builders from the orchestrator (no module cycles).
4//!
5//! Prefer [`ProgressGuard`] at phase boundaries so counters clear on every exit
6//! path (including panics via `Drop`).
7
8use std::collections::HashMap;
9use std::sync::{Mutex, OnceLock};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum IndexComponent {
13    Graph,
14    Bm25,
15    Semantic,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub struct ProgressSnapshot {
20    pub done: u64,
21    pub total: u64,
22}
23
24impl ProgressSnapshot {
25    /// `true` when a known total exists (determinate bar).
26    pub fn is_determinate(&self) -> bool {
27        self.total > 0
28    }
29
30    pub fn percent(&self) -> Option<u8> {
31        if self.total == 0 {
32            return None;
33        }
34        let pct = (self.done.saturating_mul(100)) / self.total;
35        Some(pct.min(100) as u8)
36    }
37}
38
39/// Clears the component counter when dropped (including panic unwind).
40pub struct ProgressGuard {
41    root: String,
42    component: IndexComponent,
43    cleared: bool,
44}
45
46impl ProgressGuard {
47    pub fn new(root: impl Into<String>, component: IndexComponent) -> Self {
48        Self {
49            root: root.into(),
50            component,
51            cleared: false,
52        }
53    }
54
55    pub fn report(&self, done: u64, total: u64) {
56        report(&self.root, self.component, done, total);
57    }
58
59    /// Disable auto-clear (rarely needed).
60    pub fn disarm(mut self) {
61        self.cleared = true;
62    }
63}
64
65impl Drop for ProgressGuard {
66    fn drop(&mut self) {
67        if !self.cleared {
68            clear(&self.root, self.component);
69        }
70    }
71}
72
73type ProgressMap = HashMap<(String, IndexComponent), ProgressSnapshot>;
74
75fn map() -> &'static Mutex<ProgressMap> {
76    static MAP: OnceLock<Mutex<ProgressMap>> = OnceLock::new();
77    MAP.get_or_init(|| Mutex::new(HashMap::new()))
78}
79
80/// Report progress for a project root + component.
81/// `total == 0` means indeterminate (spinner / bouncing arrow).
82pub fn report(root: &str, component: IndexComponent, done: u64, total: u64) {
83    let mut g = map()
84        .lock()
85        .unwrap_or_else(std::sync::PoisonError::into_inner);
86    g.insert(
87        (root.to_string(), component),
88        ProgressSnapshot { done, total },
89    );
90}
91
92pub fn get(root: &str, component: IndexComponent) -> ProgressSnapshot {
93    let g = map()
94        .lock()
95        .unwrap_or_else(std::sync::PoisonError::into_inner);
96    g.get(&(root.to_string(), component))
97        .copied()
98        .unwrap_or_default()
99}
100
101pub fn clear(root: &str, component: IndexComponent) {
102    let mut g = map()
103        .lock()
104        .unwrap_or_else(std::sync::PoisonError::into_inner);
105    g.remove(&(root.to_string(), component));
106}
107
108pub fn clear_root(root: &str) {
109    let mut g = map()
110        .lock()
111        .unwrap_or_else(std::sync::PoisonError::into_inner);
112    g.retain(|(r, _), _| r != root);
113}
114
115/// Convenience for BM25 file-count progress (avoids repeating the component).
116pub fn report_bm25(root: &str, done: u64, total: u64) {
117    report(root, IndexComponent::Bm25, done, total);
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn report_get_clear() {
126        let root = "__test_index_progress_report__";
127        clear_root(root);
128        report(root, IndexComponent::Bm25, 3, 10);
129        let s = get(root, IndexComponent::Bm25);
130        assert_eq!(s.done, 3);
131        assert_eq!(s.total, 10);
132        assert_eq!(s.percent(), Some(30));
133        assert!(s.is_determinate());
134        clear(root, IndexComponent::Bm25);
135        assert_eq!(get(root, IndexComponent::Bm25), ProgressSnapshot::default());
136    }
137
138    #[test]
139    fn indeterminate_when_total_zero() {
140        let root = "__test_index_progress_indet__";
141        clear_root(root);
142        report(root, IndexComponent::Graph, 0, 0);
143        let s = get(root, IndexComponent::Graph);
144        assert!(!s.is_determinate());
145        assert_eq!(s.percent(), None);
146        clear_root(root);
147    }
148
149    #[test]
150    fn percent_caps_at_100() {
151        let root = "__test_index_progress_pct__";
152        clear_root(root);
153        report(root, IndexComponent::Semantic, 15, 10);
154        assert_eq!(get(root, IndexComponent::Semantic).percent(), Some(100));
155        clear_root(root);
156    }
157
158    #[test]
159    fn guard_clears_on_drop() {
160        let root = "__test_index_progress_guard__";
161        clear_root(root);
162        {
163            let g = ProgressGuard::new(root, IndexComponent::Semantic);
164            g.report(1, 4);
165            assert_eq!(get(root, IndexComponent::Semantic).done, 1);
166        }
167        assert_eq!(
168            get(root, IndexComponent::Semantic),
169            ProgressSnapshot::default()
170        );
171        clear_root(root);
172    }
173
174    #[test]
175    fn guard_clears_after_panic() {
176        let root = "__test_index_progress_guard_panic__";
177        clear_root(root);
178        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
179            let g = ProgressGuard::new(root, IndexComponent::Bm25);
180            g.report(2, 5);
181            panic!("boom");
182        }));
183        assert_eq!(get(root, IndexComponent::Bm25), ProgressSnapshot::default());
184        clear_root(root);
185    }
186}