Skip to main content

sqry_cli/
progress.rs

1//! Progress bar implementation for CLI operations
2
3use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
4use sqry_core::progress::{IndexProgress, NodeIngestCounts, ProgressReporter};
5use std::fmt::Write;
6use std::path::Path;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10const SLOW_INGEST_WARNING_SECS: u64 = 3;
11const TOTAL_GRAPH_PHASES: u8 = 5;
12
13/// CLI progress reporter using indicatif
14pub struct CliProgressReporter {
15    multi: MultiProgress,
16    file_bar: ProgressBar,
17    stage_bar: ProgressBar,
18    file_style: ProgressStyle,
19    stage_bar_style: ProgressStyle,
20    stage_spinner_style: ProgressStyle,
21    state: Mutex<CliProgressState>,
22}
23
24#[derive(Default)]
25struct CliProgressState {
26    total_files: Option<usize>,
27    file_bar_finished: bool,
28    last_ingest_file: Option<String>,
29}
30
31impl CliProgressReporter {
32    /// Create a new CLI progress reporter
33    ///
34    /// # Panics
35    /// Panics if the progress bar template string is invalid.
36    #[must_use]
37    pub fn new() -> Self {
38        let multi = MultiProgress::new();
39        let file_bar = multi.add(ProgressBar::new(0));
40        let stage_bar = multi.add(ProgressBar::new_spinner());
41
42        let file_style = ProgressStyle::default_bar()
43            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} files | {msg}")
44            .unwrap()
45            .progress_chars("=>-");
46        let stage_bar_style = ProgressStyle::default_bar()
47            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | {msg}")
48            .unwrap()
49            .progress_chars("=>-");
50        let stage_spinner_style = ProgressStyle::default_spinner()
51            .template("{spinner:.green} {msg}")
52            .unwrap();
53
54        file_bar.set_style(file_style.clone());
55        stage_bar.set_style(stage_spinner_style.clone());
56        stage_bar.enable_steady_tick(std::time::Duration::from_millis(120));
57
58        Self {
59            multi,
60            file_bar,
61            stage_bar,
62            file_style,
63            stage_bar_style,
64            stage_spinner_style,
65            state: Mutex::new(CliProgressState::default()),
66        }
67    }
68
69    /// Finish and clear the progress bar
70    pub fn finish(&self) {
71        self.file_bar.finish_and_clear();
72        self.stage_bar.finish_and_clear();
73        let _ = self.multi.clear();
74    }
75
76    fn handle_started(&self, total_files: usize) {
77        let mut state = self.state.lock().unwrap();
78        state.total_files = Some(total_files);
79        self.file_bar.set_style(self.file_style.clone());
80        self.file_bar.set_length(total_files as u64);
81        self.file_bar.set_position(0);
82        self.file_bar.set_message("Indexing files");
83        self.stage_bar.set_style(self.stage_spinner_style.clone());
84        self.stage_bar.set_message("Waiting for ingestion...");
85    }
86
87    fn handle_file_processing(&self, path: &Path, current: usize) {
88        self.file_bar.set_style(self.file_style.clone());
89        self.file_bar.set_position(current as u64);
90        let file_name = path
91            .file_name()
92            .and_then(|n| n.to_str())
93            .unwrap_or("unknown");
94        self.file_bar.set_message(file_name.to_string());
95        let mut state = self.state.lock().unwrap();
96        if let Some(total_files) = state.total_files
97            && current >= total_files
98            && !state.file_bar_finished
99        {
100            self.file_bar
101                .finish_with_message(format!("Files indexed: {total_files}"));
102            state.file_bar_finished = true;
103        }
104    }
105
106    fn handle_file_completed(&self, symbols: usize) {
107        self.file_bar.set_message(format!("{symbols} symbols"));
108    }
109
110    fn handle_ingest_progress(
111        &self,
112        files_processed: usize,
113        total_files: usize,
114        total_symbols: usize,
115        counts: &NodeIngestCounts,
116        elapsed: std::time::Duration,
117        eta: Option<std::time::Duration>,
118    ) {
119        self.stage_bar.set_style(self.stage_bar_style.clone());
120        self.stage_bar.set_length(total_files as u64);
121        self.stage_bar.set_position(files_processed as u64);
122        let rate = format_rate(files_processed, elapsed);
123        let eta_display = eta.map_or_else(|| "--:--".to_string(), format_duration_clock);
124        let elapsed_display = format_duration_clock(elapsed);
125        let file_hint = self.current_ingest_file();
126        let file_suffix = file_hint
127            .as_deref()
128            .map(|name| format!(" | file: {name}"))
129            .unwrap_or_default();
130        let mut message = format!(
131            "Ingesting symbols: {total_symbols} symbols | elapsed {elapsed_display} | eta {eta_display} | {rate}{file_suffix}"
132        );
133        let _ = write!(message, "\n({})", format_ingest_counts(counts));
134        self.stage_bar.set_message(message);
135    }
136
137    fn handle_ingest_file_started(&self, path: &Path) {
138        let file_label = ingest_file_label(path);
139        {
140            let mut state = self.state.lock().unwrap();
141            state.last_ingest_file = Some(file_label.clone());
142        }
143        self.stage_bar.set_style(self.stage_bar_style.clone());
144        self.stage_bar
145            .set_message(format!("Ingesting {file_label}..."));
146    }
147
148    fn handle_ingest_file_completed(&self, path: &Path, symbols: usize, duration: Duration) {
149        if is_slow_ingest(duration) {
150            let warning = format!(
151                "Warning: slow ingest ({duration:.2?}, {symbols} symbols): {}",
152                path.display()
153            );
154            self.stage_bar.println(warning);
155        }
156    }
157
158    fn current_ingest_file(&self) -> Option<String> {
159        let state = self.state.lock().unwrap();
160        state.last_ingest_file.clone()
161    }
162
163    fn handle_stage_started(&self, stage_name: &str) {
164        self.stage_bar.set_style(self.stage_spinner_style.clone());
165        self.stage_bar.set_message(format!("{stage_name}..."));
166    }
167
168    fn handle_stage_completed(&self, stage_name: &str, stage_duration: std::time::Duration) {
169        self.stage_bar.set_style(self.stage_spinner_style.clone());
170        self.stage_bar
171            .set_message(format!("{stage_name} completed in {stage_duration:.2?}"));
172    }
173
174    fn handle_graph_phase_started(&self, phase_number: u8, phase_name: &str, total_items: usize) {
175        if total_items == 0 {
176            // Use spinner style when total is unknown/zero to avoid stuck "0/0" display
177            self.stage_bar.set_style(self.stage_spinner_style.clone());
178        } else {
179            self.stage_bar.set_style(self.stage_bar_style.clone());
180            self.stage_bar.set_length(total_items as u64);
181        }
182        self.stage_bar.set_position(0);
183        self.stage_bar
184            .set_message(format_graph_phase_message(phase_number, phase_name));
185    }
186
187    fn handle_graph_phase_progress(&self, items_processed: usize, total_items: usize) {
188        self.stage_bar.set_position(items_processed as u64);
189        if self.stage_bar.length() != Some(total_items as u64) {
190            self.stage_bar.set_length(total_items as u64);
191        }
192    }
193
194    fn handle_graph_phase_completed(
195        &self,
196        phase_number: u8,
197        phase_name: &str,
198        phase_duration: std::time::Duration,
199    ) {
200        self.stage_bar.set_message(format!(
201            "{} completed in {phase_duration:.2?}",
202            format_graph_phase_message(phase_number, phase_name)
203        ));
204    }
205
206    fn handle_saving_started(&self, component_name: &str) {
207        self.stage_bar.set_style(self.stage_spinner_style.clone());
208        self.stage_bar
209            .set_message(format!("Saving {component_name}..."));
210    }
211
212    fn handle_saving_completed(&self, component_name: &str, save_duration: std::time::Duration) {
213        self.stage_bar
214            .set_message(format!("Saved {component_name} in {save_duration:.2?}"));
215    }
216
217    fn handle_completed(&self, total_symbols: usize, duration: std::time::Duration) {
218        self.stage_bar
219            .set_message(format!("Indexed {total_symbols} symbols in {duration:.2?}"));
220    }
221}
222
223impl ProgressReporter for CliProgressReporter {
224    fn report(&self, event: IndexProgress) {
225        match event {
226            IndexProgress::Started { total_files } => {
227                self.handle_started(total_files);
228            }
229            IndexProgress::FileProcessing {
230                path,
231                current,
232                total: _,
233            } => {
234                self.handle_file_processing(&path, current);
235            }
236            IndexProgress::FileCompleted { symbols, .. } => {
237                self.handle_file_completed(symbols);
238            }
239            IndexProgress::IngestProgress {
240                files_processed,
241                total_files,
242                total_symbols,
243                counts,
244                elapsed,
245                eta,
246            } => {
247                self.handle_ingest_progress(
248                    files_processed,
249                    total_files,
250                    total_symbols,
251                    &counts,
252                    elapsed,
253                    eta,
254                );
255            }
256            IndexProgress::IngestFileStarted { path, .. } => {
257                self.handle_ingest_file_started(&path);
258            }
259            IndexProgress::IngestFileCompleted {
260                path,
261                symbols,
262                duration,
263            } => {
264                self.handle_ingest_file_completed(&path, symbols, duration);
265            }
266            IndexProgress::StageStarted { stage_name } => {
267                self.handle_stage_started(stage_name);
268            }
269            IndexProgress::StageCompleted {
270                stage_name,
271                stage_duration,
272            } => {
273                self.handle_stage_completed(stage_name, stage_duration);
274            }
275            // Graph build phase events
276            IndexProgress::GraphPhaseStarted {
277                phase_number,
278                phase_name,
279                total_items,
280            } => {
281                self.handle_graph_phase_started(phase_number, phase_name, total_items);
282            }
283            IndexProgress::GraphPhaseProgress {
284                items_processed,
285                total_items,
286                ..
287            } => {
288                self.handle_graph_phase_progress(items_processed, total_items);
289            }
290            IndexProgress::GraphPhaseCompleted {
291                phase_number,
292                phase_name,
293                phase_duration,
294            } => {
295                self.handle_graph_phase_completed(phase_number, phase_name, phase_duration);
296            }
297            // Saving events
298            IndexProgress::SavingStarted { component_name } => {
299                self.handle_saving_started(component_name);
300            }
301            IndexProgress::SavingCompleted {
302                component_name,
303                save_duration,
304            } => {
305                self.handle_saving_completed(component_name, save_duration);
306            }
307            // Final completion - update message but don't finish the bar
308            // The bar is finished explicitly via finish() method after all phases complete
309            IndexProgress::Completed {
310                total_symbols,
311                duration,
312            } => {
313                self.handle_completed(total_symbols, duration);
314            }
315            // Handle any future variants gracefully
316            _ => {}
317        }
318    }
319}
320
321fn format_ingest_counts(counts: &NodeIngestCounts) -> String {
322    let mut parts = Vec::new();
323    parts.push(format!("fn {}", format_count(counts.functions)));
324    parts.push(format!("mth {}", format_count(counts.methods)));
325    parts.push(format!("cls {}", format_count(counts.classes)));
326    if counts.structs > 0 {
327        parts.push(format!("struct {}", format_count(counts.structs)));
328    }
329    if counts.enums > 0 {
330        parts.push(format!("enum {}", format_count(counts.enums)));
331    }
332    if counts.interfaces > 0 {
333        parts.push(format!("iface {}", format_count(counts.interfaces)));
334    }
335    if counts.other > 0 {
336        parts.push(format!("other {}", format_count(counts.other)));
337    }
338    parts.join(", ")
339}
340
341fn format_graph_phase_message(phase_number: u8, phase_name: &str) -> String {
342    if phase_number == 1
343        && phase_name == "Chunked structural indexing (parse -> range-plan -> semantic commit)"
344    {
345        return format!("Phase 1-3/{TOTAL_GRAPH_PHASES}: {phase_name}");
346    }
347    format!("Phase {phase_number}/{TOTAL_GRAPH_PHASES}: {phase_name}")
348}
349
350fn ingest_file_label(path: &Path) -> String {
351    path.file_name()
352        .and_then(|name| name.to_str())
353        .map_or_else(|| path.display().to_string(), ToString::to_string)
354}
355
356fn is_slow_ingest(duration: Duration) -> bool {
357    duration >= Duration::from_secs(SLOW_INGEST_WARNING_SECS)
358}
359
360fn format_count(value: usize) -> String {
361    if value < 1_000 {
362        return value.to_string();
363    }
364    let thousands = value / 1_000;
365    let remainder = value % 1_000;
366    if thousands < 10 {
367        let tenths = remainder / 100;
368        if tenths == 0 {
369            format!("{thousands}k")
370        } else {
371            format!("{thousands}.{tenths}k")
372        }
373    } else {
374        format!("{thousands}k")
375    }
376}
377
378fn format_rate(files_processed: usize, elapsed: std::time::Duration) -> String {
379    let elapsed_ms = elapsed.as_millis();
380    if elapsed_ms == 0 {
381        return "0 files/sec".to_string();
382    }
383    let files_processed = u128::from(files_processed as u64);
384    let rate = (files_processed * 1_000) / elapsed_ms;
385    format!("{rate} files/sec")
386}
387
388fn format_duration_clock(duration: std::time::Duration) -> String {
389    let secs = duration.as_secs();
390    let minutes = secs / 60;
391    let seconds = secs % 60;
392    if minutes < 60 {
393        return format!("{minutes:02}:{seconds:02}");
394    }
395    let hours = minutes / 60;
396    let rem_minutes = minutes % 60;
397    format!("{hours}h{rem_minutes:02}m")
398}
399
400/// Step-level progress reporter for non-TTY output.
401///
402/// Emits coarse-grained progress messages without spamming.
403pub struct CliStepProgressReporter {
404    state: Mutex<StepState>,
405}
406
407#[derive(Default)]
408struct StepState {
409    total_files: Option<usize>,
410}
411
412impl CliStepProgressReporter {
413    #[must_use]
414    pub fn new() -> Self {
415        Self {
416            state: Mutex::new(StepState::default()),
417        }
418    }
419}
420
421impl Default for CliStepProgressReporter {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl ProgressReporter for CliStepProgressReporter {
428    fn report(&self, event: IndexProgress) {
429        match event {
430            IndexProgress::Started { total_files } => {
431                let mut state = self.state.lock().unwrap();
432                state.total_files = Some(total_files);
433                println!("Indexing {total_files} files...");
434            }
435            IndexProgress::GraphPhaseStarted {
436                phase_number,
437                phase_name,
438                total_items,
439            } => {
440                println!(
441                    "{} ({total_items} items)...",
442                    format_graph_phase_message(phase_number, phase_name)
443                );
444            }
445            IndexProgress::GraphPhaseCompleted {
446                phase_number,
447                phase_name,
448                phase_duration,
449            } => {
450                println!(
451                    "{} completed in {phase_duration:.2?}",
452                    format_graph_phase_message(phase_number, phase_name)
453                );
454            }
455            IndexProgress::IngestProgress {
456                files_processed,
457                total_files: _,
458                total_symbols,
459                counts,
460                elapsed,
461                eta,
462            } => {
463                let rate = format_rate(files_processed, elapsed);
464                let eta_display = eta.map_or_else(|| "--:--".to_string(), format_duration_clock);
465                let elapsed_display = format_duration_clock(elapsed);
466                println!(
467                    "Ingesting symbols: {total_symbols} symbols | elapsed {elapsed_display} | eta {eta_display} | {rate}"
468                );
469                println!("({})", format_ingest_counts(&counts));
470            }
471            IndexProgress::IngestFileCompleted {
472                path,
473                symbols,
474                duration,
475            } => {
476                if is_slow_ingest(duration) {
477                    println!(
478                        "Warning: slow ingest ({duration:.2?}, {symbols} symbols): {}",
479                        path.display()
480                    );
481                }
482            }
483            IndexProgress::StageStarted { stage_name } => {
484                println!("Stage: {stage_name}...");
485            }
486            IndexProgress::StageCompleted {
487                stage_name,
488                stage_duration,
489            } => {
490                println!("Stage: {stage_name} completed in {stage_duration:.2?}");
491            }
492            IndexProgress::SavingStarted { component_name } => {
493                println!("Saving {component_name}...");
494            }
495            IndexProgress::SavingCompleted {
496                component_name,
497                save_duration,
498            } => {
499                println!("Saved {component_name} in {save_duration:.2?}");
500            }
501            IndexProgress::Completed {
502                total_symbols,
503                duration,
504            } => {
505                let total_files = self
506                    .state
507                    .lock()
508                    .unwrap()
509                    .total_files
510                    .map_or_else(String::new, |count| format!(" across {count} files"));
511                println!("Indexed {total_symbols} symbols{total_files} in {duration:.2?}");
512            }
513            _ => {}
514        }
515    }
516}
517
518/// Step runner for coarse-grained progress reporting.
519pub struct StepRunner {
520    enabled: bool,
521    step_index: usize,
522}
523
524impl StepRunner {
525    #[must_use]
526    pub fn new(enabled: bool) -> Self {
527        Self {
528            enabled,
529            step_index: 0,
530        }
531    }
532
533    /// Run a named step and emit start/finish lines when enabled.
534    ///
535    /// # Errors
536    ///
537    /// Returns any error produced by the step action.
538    pub fn step<T, E, F>(&mut self, name: &str, action: F) -> Result<T, E>
539    where
540        E: std::fmt::Display,
541        F: FnOnce() -> Result<T, E>,
542    {
543        self.step_index += 1;
544        let step_number = self.step_index;
545        if self.enabled {
546            println!("Step {step_number}: {name}...");
547        }
548        let start = Instant::now();
549        let result = action();
550        if self.enabled {
551            match &result {
552                Ok(_) => println!(
553                    "Step {step_number}: {name} completed in {:.2?}",
554                    start.elapsed()
555                ),
556                Err(err) => println!(
557                    "Step {step_number}: {name} failed after {:.2?}: {err}",
558                    start.elapsed()
559                ),
560            }
561        }
562        result
563    }
564}
565
566impl Default for CliProgressReporter {
567    fn default() -> Self {
568        Self::new()
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::{format_duration_clock, format_graph_phase_message, format_rate};
575    use std::time::Duration;
576
577    #[test]
578    fn test_format_rate_zero_elapsed() {
579        assert_eq!(format_rate(0, Duration::from_secs(0)), "0 files/sec");
580    }
581
582    #[test]
583    fn test_format_rate_per_second() {
584        assert_eq!(format_rate(1000, Duration::from_secs(1)), "1000 files/sec");
585    }
586
587    #[test]
588    fn test_format_rate_fractional_seconds() {
589        assert_eq!(format_rate(1500, Duration::from_secs(2)), "750 files/sec");
590    }
591
592    #[test]
593    fn test_format_duration_clock_under_hour() {
594        assert_eq!(format_duration_clock(Duration::from_secs(65)), "01:05");
595    }
596
597    #[test]
598    fn test_format_duration_clock_hour_boundary() {
599        assert_eq!(format_duration_clock(Duration::from_secs(3600)), "1h00m");
600    }
601
602    #[test]
603    fn test_format_duration_clock_hours_minutes() {
604        assert_eq!(format_duration_clock(Duration::from_secs(3720)), "1h02m");
605    }
606
607    #[test]
608    fn test_format_graph_phase_message() {
609        assert_eq!(
610            format_graph_phase_message(
611                1,
612                "Chunked structural indexing (parse -> range-plan -> semantic commit)"
613            ),
614            "Phase 1-3/5: Chunked structural indexing (parse -> range-plan -> semantic commit)"
615        );
616    }
617}