1use 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
13pub 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 #[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 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 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 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 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 IndexProgress::Completed {
310 total_symbols,
311 duration,
312 } => {
313 self.handle_completed(total_symbols, duration);
314 }
315 _ => {}
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
400pub 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
518pub 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 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}