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}