Skip to main content

sqry_core/
progress.rs

1//! Progress reporting for indexing operations.
2//!
3//! This module provides types for tracking and reporting progress during
4//! graph indexing operations. Progress events can be used to implement
5//! progress bars, logging, or other user feedback mechanisms.
6//!
7//! # Example
8//!
9//! ```rust
10//! use sqry_core::progress::{IndexProgress, ProgressReporter};
11//! use std::sync::Arc;
12//!
13//! struct ConsoleProgress;
14//!
15//! impl ProgressReporter for ConsoleProgress {
16//!     fn report(&self, event: IndexProgress) {
17//!         match event {
18//!             IndexProgress::Started { total_files } => {
19//!                 println!("Starting to index {} files...", total_files);
20//!             }
21//!             IndexProgress::FileCompleted { path, symbols } => {
22//!                 println!("Processed {:?}: {} items", path, symbols);
23//!             }
24//!             IndexProgress::Completed { total_symbols, duration } => {
25//!                 println!("Indexed {} items in {:?}", total_symbols, duration);
26//!             }
27//!             _ => {}
28//!         }
29//!     }
30//! }
31//!
32//! // Use ConsoleProgress when building graphs with progress reporting
33//! let reporter: Arc<dyn ProgressReporter> = Arc::new(ConsoleProgress);
34//! ```
35
36use std::path::PathBuf;
37use std::sync::Arc;
38use std::time::{Duration, Instant};
39
40use crate::graph::NodeKind;
41
42/// Progress events emitted during indexing operations.
43///
44/// # Stability
45///
46/// This enum is marked `#[non_exhaustive]` to allow adding new progress events
47/// in future versions without breaking downstream code. Always include a
48/// wildcard arm (`_ => {}`) when matching on this enum.
49#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub enum IndexProgress {
52    // === File Processing Events ===
53    /// Indexing has started.
54    Started {
55        /// Total number of files to process
56        total_files: usize,
57    },
58
59    /// A file is currently being processed.
60    FileProcessing {
61        /// Path to the file being processed
62        path: PathBuf,
63        /// Current file number (1-based)
64        current: usize,
65        /// Total number of files
66        total: usize,
67    },
68
69    /// A file has been processed successfully.
70    FileCompleted {
71        /// Path to the completed file
72        path: PathBuf,
73        /// Number of items extracted from this file
74        symbols: usize,
75    },
76
77    // === Ingest Progress ===
78    /// Progress while ingesting nodes and relations into the index.
79    IngestProgress {
80        /// Files ingested so far
81        files_processed: usize,
82        /// Total files to ingest
83        total_files: usize,
84        /// Total items ingested so far
85        total_symbols: usize,
86        /// Node-kind breakdown
87        counts: NodeIngestCounts,
88        /// Elapsed time for ingestion
89        elapsed: Duration,
90        /// Estimated remaining time (best-effort)
91        eta: Option<Duration>,
92    },
93
94    /// A file is about to be ingested into the index.
95    IngestFileStarted {
96        /// Path to the file being ingested
97        path: PathBuf,
98        /// Current file number (1-based)
99        current: usize,
100        /// Total number of files to ingest
101        total: usize,
102    },
103
104    /// A file has finished ingesting into the index.
105    IngestFileCompleted {
106        /// Path to the ingested file
107        path: PathBuf,
108        /// Number of items ingested from this file
109        symbols: usize,
110        /// Duration of the ingest work for this file
111        duration: Duration,
112    },
113
114    // === Stage Events ===
115    /// A coarse-grained indexing stage has started.
116    StageStarted {
117        /// Human-readable stage name (e.g., "Resolve imports")
118        /// Uses `&'static str` to avoid allocations.
119        stage_name: &'static str,
120    },
121
122    /// A coarse-grained indexing stage has completed.
123    StageCompleted {
124        /// Human-readable stage name
125        stage_name: &'static str,
126        /// Duration of the stage
127        stage_duration: Duration,
128    },
129
130    // === Graph Building Events ===
131    /// A graph build phase has started.
132    GraphPhaseStarted {
133        /// Phase number (1-4)
134        phase_number: u8,
135        /// Human-readable phase name (e.g., "AST extraction")
136        /// Uses `&'static str` to avoid allocations in hot paths.
137        phase_name: &'static str,
138        /// Total items to process in this phase
139        total_items: usize,
140    },
141
142    /// Progress within a graph build phase.
143    GraphPhaseProgress {
144        /// Phase number (1-4)
145        phase_number: u8,
146        /// Number of items processed so far
147        items_processed: usize,
148        /// Total items in this phase
149        total_items: usize,
150    },
151
152    /// A graph build phase has completed.
153    GraphPhaseCompleted {
154        /// Phase number (1-4)
155        phase_number: u8,
156        /// Human-readable phase name
157        phase_name: &'static str,
158        /// Duration of this phase
159        phase_duration: Duration,
160    },
161
162    // === Index Saving Events ===
163    /// Index save operation has started for a component.
164    SavingStarted {
165        /// Component being saved (e.g., "symbols", "trigrams", "unified graph")
166        /// Uses `&'static str` to avoid allocations.
167        component_name: &'static str,
168    },
169
170    /// Index save operation has completed for a component.
171    SavingCompleted {
172        /// Component that was saved
173        component_name: &'static str,
174        /// Duration of the save operation
175        save_duration: Duration,
176    },
177
178    // === Completion Event ===
179    /// Indexing has completed.
180    Completed {
181        /// Total number of items indexed
182        total_symbols: usize,
183        /// Duration of the indexing operation
184        duration: Duration,
185    },
186}
187
188/// Trait for reporting progress during indexing operations.
189///
190/// Implementors can display progress bars, log events, or perform
191/// other actions in response to indexing progress.
192///
193/// # Thread Safety
194///
195/// Implementations must be `Send + Sync` to support parallel indexing.
196/// Progress events may be reported from multiple threads concurrently.
197pub trait ProgressReporter: Send + Sync {
198    /// Report a progress event.
199    ///
200    /// This method is called during indexing to report progress.
201    /// Implementations should be non-blocking to avoid slowing down
202    /// the indexing process.
203    fn report(&self, event: IndexProgress);
204}
205
206/// Helper for emitting coarse-grained stage progress.
207pub struct ProgressStage {
208    reporter: SharedReporter,
209    stage_name: &'static str,
210    start: Instant,
211}
212
213impl ProgressStage {
214    /// Emit a stage start event and return a timer for completion.
215    #[must_use]
216    pub fn start(reporter: &SharedReporter, stage_name: &'static str) -> Self {
217        reporter.report(IndexProgress::StageStarted { stage_name });
218        Self {
219            reporter: Arc::clone(reporter),
220            stage_name,
221            start: Instant::now(),
222        }
223    }
224
225    /// Emit a stage completion event.
226    pub fn finish(self) {
227        self.reporter.report(IndexProgress::StageCompleted {
228            stage_name: self.stage_name,
229            stage_duration: self.start.elapsed(),
230        });
231    }
232}
233
234/// Node-kind counters for ingestion progress reporting.
235#[derive(Debug, Clone, Default)]
236pub struct NodeIngestCounts {
237    /// Function nodes.
238    pub functions: usize,
239    /// Class nodes.
240    pub classes: usize,
241    /// Method nodes.
242    pub methods: usize,
243    /// Struct nodes.
244    pub structs: usize,
245    /// Enum nodes.
246    pub enums: usize,
247    /// Interface/trait nodes.
248    pub interfaces: usize,
249    /// Variable-like nodes (variables, properties, parameters).
250    pub variables: usize,
251    /// Constant nodes.
252    pub constants: usize,
253    /// Type alias nodes.
254    pub types: usize,
255    /// Module nodes.
256    pub modules: usize,
257    /// All other nodes not covered by the explicit buckets.
258    pub other: usize,
259}
260
261impl NodeIngestCounts {
262    /// Add a single node kind to the appropriate counter.
263    pub fn add_node_kind(&mut self, kind: &NodeKind) {
264        match kind {
265            NodeKind::Function { .. } => self.functions += 1,
266            NodeKind::Class { .. } => self.classes += 1,
267            NodeKind::Module { .. } => self.modules += 1,
268            NodeKind::Variable { .. } => self.variables += 1,
269        }
270    }
271
272    /// Add a slice of node kinds to the counters.
273    pub fn add_node_kinds(&mut self, kinds: &[NodeKind]) {
274        for kind in kinds {
275            self.add_node_kind(kind);
276        }
277    }
278
279    /// Total number of nodes across all buckets.
280    #[must_use]
281    pub fn total(&self) -> usize {
282        self.functions
283            + self.classes
284            + self.methods
285            + self.structs
286            + self.enums
287            + self.interfaces
288            + self.variables
289            + self.constants
290            + self.types
291            + self.modules
292            + self.other
293    }
294}
295
296/// Time-throttled ingestion progress tracker.
297pub struct IngestProgressTracker {
298    reporter: SharedReporter,
299    total_files: usize,
300    processed_files: usize,
301    counts: NodeIngestCounts,
302    start: Instant,
303    last_emit: Instant,
304}
305
306impl IngestProgressTracker {
307    /// Create a new ingestion progress tracker.
308    #[must_use]
309    pub fn new(reporter: &SharedReporter, total_files: usize) -> Self {
310        let now = Instant::now();
311        Self {
312            reporter: Arc::clone(reporter),
313            total_files,
314            processed_files: 0,
315            counts: NodeIngestCounts::default(),
316            start: now,
317            last_emit: now,
318        }
319    }
320
321    /// Record the node kinds ingested for one file and emit a progress update if needed.
322    pub fn record_node_kinds(&mut self, kinds: &[NodeKind]) {
323        self.processed_files = self.processed_files.saturating_add(1);
324        self.counts.add_node_kinds(kinds);
325        self.maybe_emit(false);
326    }
327
328    /// Emit a final progress update.
329    pub fn finish(&mut self) {
330        self.maybe_emit(true);
331    }
332
333    fn maybe_emit(&mut self, force: bool) {
334        let now = Instant::now();
335        let elapsed = now.duration_since(self.start);
336        if !force && now.duration_since(self.last_emit) < Duration::from_millis(800) {
337            return;
338        }
339        self.last_emit = now;
340
341        let eta = self.estimate_eta(elapsed);
342        self.reporter.report(IndexProgress::IngestProgress {
343            files_processed: self.processed_files,
344            total_files: self.total_files,
345            total_symbols: self.counts.total(),
346            counts: self.counts.clone(),
347            elapsed,
348            eta,
349        });
350    }
351
352    fn estimate_eta(&self, elapsed: Duration) -> Option<Duration> {
353        if self.processed_files == 0 || self.total_files == 0 {
354            return None;
355        }
356        let elapsed_nanos = elapsed.as_nanos();
357        if elapsed_nanos == 0 {
358            return None;
359        }
360        let processed_files = u128::from(self.processed_files as u64);
361        let remaining_files =
362            u128::from(self.total_files.saturating_sub(self.processed_files) as u64);
363        if processed_files == 0 || remaining_files == 0 {
364            return Some(Duration::from_secs(0));
365        }
366        let nanos_per_file = elapsed_nanos / processed_files;
367        let remaining_nanos = nanos_per_file.saturating_mul(remaining_files);
368        let remaining_nanos_u64 = u64::try_from(remaining_nanos).ok()?;
369        Some(Duration::from_nanos(remaining_nanos_u64))
370    }
371}
372
373/// A no-op progress reporter that discards all events.
374///
375/// This is the default reporter used when no progress reporting is needed.
376#[derive(Debug, Clone, Copy)]
377pub struct NoOpReporter;
378
379impl ProgressReporter for NoOpReporter {
380    fn report(&self, _event: IndexProgress) {
381        // Intentionally empty - no progress reporting
382    }
383}
384
385/// Type alias for a shared progress reporter.
386pub type SharedReporter = Arc<dyn ProgressReporter>;
387
388/// Creates a new no-op reporter.
389///
390/// This is useful as a default when no progress reporting is needed.
391#[must_use]
392pub fn no_op_reporter() -> SharedReporter {
393    Arc::new(NoOpReporter)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::sync::Mutex;
400
401    struct TestReporter {
402        events: Mutex<Vec<IndexProgress>>,
403    }
404
405    impl TestReporter {
406        fn new() -> Self {
407            Self {
408                events: Mutex::new(Vec::new()),
409            }
410        }
411
412        fn events(&self) -> Vec<IndexProgress> {
413            self.events.lock().unwrap().clone()
414        }
415    }
416
417    impl ProgressReporter for TestReporter {
418        fn report(&self, event: IndexProgress) {
419            self.events.lock().unwrap().push(event);
420        }
421    }
422
423    #[test]
424    fn test_progress_event_sequence() {
425        let reporter = TestReporter::new();
426
427        // Simulate indexing progress
428        reporter.report(IndexProgress::Started { total_files: 2 });
429        reporter.report(IndexProgress::FileProcessing {
430            path: PathBuf::from("file1.rs"),
431            current: 1,
432            total: 2,
433        });
434        reporter.report(IndexProgress::FileCompleted {
435            path: PathBuf::from("file1.rs"),
436            symbols: 10,
437        });
438        reporter.report(IndexProgress::FileProcessing {
439            path: PathBuf::from("file2.rs"),
440            current: 2,
441            total: 2,
442        });
443        reporter.report(IndexProgress::FileCompleted {
444            path: PathBuf::from("file2.rs"),
445            symbols: 15,
446        });
447        reporter.report(IndexProgress::Completed {
448            total_symbols: 25,
449            duration: Duration::from_secs(1),
450        });
451
452        let events = reporter.events();
453        assert_eq!(events.len(), 6);
454
455        // Verify event types in order
456        matches!(events[0], IndexProgress::Started { .. });
457        matches!(events[1], IndexProgress::FileProcessing { .. });
458        matches!(events[2], IndexProgress::FileCompleted { .. });
459        matches!(events[3], IndexProgress::FileProcessing { .. });
460        matches!(events[4], IndexProgress::FileCompleted { .. });
461        matches!(events[5], IndexProgress::Completed { .. });
462    }
463
464    #[test]
465    fn test_no_op_reporter() {
466        let reporter = no_op_reporter();
467
468        // Should not panic or produce side effects
469        reporter.report(IndexProgress::Started { total_files: 5 });
470        reporter.report(IndexProgress::Completed {
471            total_symbols: 100,
472            duration: Duration::from_millis(500),
473        });
474    }
475
476    #[test]
477    fn test_reporter_is_send_sync() {
478        fn assert_send_sync<T: Send + Sync>() {}
479        assert_send_sync::<NoOpReporter>();
480        assert_send_sync::<TestReporter>();
481    }
482}