1use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
4use sqry_core::progress::{
5 IndexProgress, NodeIngestCounts, ProgressReporter, SharedReporter, no_op_reporter,
6};
7use std::fmt::Write;
8use std::io::{self, Write as IoWrite};
9use std::path::Path;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13const SLOW_INGEST_WARNING_SECS: u64 = 3;
14const TOTAL_GRAPH_PHASES: u8 = 8;
26
27pub struct CliProgressReporter {
29 multi: MultiProgress,
30 file_bar: ProgressBar,
31 stage_bar: ProgressBar,
32 file_style: ProgressStyle,
33 stage_bar_style: ProgressStyle,
34 stage_spinner_style: ProgressStyle,
35 state: Mutex<CliProgressState>,
36}
37
38#[derive(Default)]
39struct CliProgressState {
40 total_files: Option<usize>,
41 file_bar_finished: bool,
42 last_ingest_file: Option<String>,
43}
44
45impl CliProgressReporter {
46 #[must_use]
51 pub fn new() -> Self {
52 let multi = MultiProgress::new();
53 let file_bar = multi.add(ProgressBar::new(0));
54 let stage_bar = multi.add(ProgressBar::new_spinner());
55
56 let file_style = ProgressStyle::default_bar()
57 .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} files | {msg}")
58 .unwrap()
59 .progress_chars("=>-");
60 let stage_bar_style = ProgressStyle::default_bar()
61 .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | {msg}")
62 .unwrap()
63 .progress_chars("=>-");
64 let stage_spinner_style = ProgressStyle::default_spinner()
65 .template("{spinner:.green} {msg}")
66 .unwrap();
67
68 file_bar.set_style(file_style.clone());
69 stage_bar.set_style(stage_spinner_style.clone());
70 stage_bar.enable_steady_tick(std::time::Duration::from_millis(120));
71
72 Self {
73 multi,
74 file_bar,
75 stage_bar,
76 file_style,
77 stage_bar_style,
78 stage_spinner_style,
79 state: Mutex::new(CliProgressState::default()),
80 }
81 }
82
83 pub fn finish(&self) {
85 self.file_bar.finish_and_clear();
86 self.stage_bar.finish_and_clear();
87 let _ = self.multi.clear();
88 }
89
90 fn handle_started(&self, total_files: usize) {
91 let mut state = self.state.lock().unwrap();
92 state.total_files = Some(total_files);
93 self.file_bar.set_style(self.file_style.clone());
94 self.file_bar.set_length(total_files as u64);
95 self.file_bar.set_position(0);
96 self.file_bar.set_message("Indexing files");
97 self.stage_bar.set_style(self.stage_spinner_style.clone());
98 self.stage_bar.set_message("Waiting for ingestion...");
99 }
100
101 fn handle_file_processing(&self, path: &Path, current: usize) {
102 self.file_bar.set_style(self.file_style.clone());
103 self.file_bar.set_position(current as u64);
104 let file_name = path
105 .file_name()
106 .and_then(|n| n.to_str())
107 .unwrap_or("unknown");
108 self.file_bar.set_message(file_name.to_string());
109 let mut state = self.state.lock().unwrap();
110 if let Some(total_files) = state.total_files
111 && current >= total_files
112 && !state.file_bar_finished
113 {
114 self.file_bar
115 .finish_with_message(format!("Files indexed: {total_files}"));
116 state.file_bar_finished = true;
117 }
118 }
119
120 fn handle_file_completed(&self, symbols: usize) {
121 self.file_bar.set_message(format!("{symbols} symbols"));
122 }
123
124 fn handle_ingest_progress(
125 &self,
126 files_processed: usize,
127 total_files: usize,
128 total_symbols: usize,
129 counts: &NodeIngestCounts,
130 elapsed: std::time::Duration,
131 eta: Option<std::time::Duration>,
132 ) {
133 self.stage_bar.set_style(self.stage_bar_style.clone());
134 self.stage_bar.set_length(total_files as u64);
135 self.stage_bar.set_position(files_processed as u64);
136 let rate = format_rate(files_processed, elapsed);
137 let eta_display = eta.map_or_else(|| "--:--".to_string(), format_duration_clock);
138 let elapsed_display = format_duration_clock(elapsed);
139 let file_hint = self.current_ingest_file();
140 let file_suffix = file_hint
141 .as_deref()
142 .map(|name| format!(" | file: {name}"))
143 .unwrap_or_default();
144 let mut message = format!(
145 "Ingesting symbols: {total_symbols} symbols | elapsed {elapsed_display} | eta {eta_display} | {rate}{file_suffix}"
146 );
147 let _ = write!(message, "\n({})", format_ingest_counts(counts));
148 self.stage_bar.set_message(message);
149 }
150
151 fn handle_ingest_file_started(&self, path: &Path) {
152 let file_label = ingest_file_label(path);
153 {
154 let mut state = self.state.lock().unwrap();
155 state.last_ingest_file = Some(file_label.clone());
156 }
157 self.stage_bar.set_style(self.stage_bar_style.clone());
158 self.stage_bar
159 .set_message(format!("Ingesting {file_label}..."));
160 }
161
162 fn handle_ingest_file_completed(&self, path: &Path, symbols: usize, duration: Duration) {
163 if is_slow_ingest(duration) {
164 let warning = format!(
165 "Warning: slow ingest ({duration:.2?}, {symbols} symbols): {}",
166 path.display()
167 );
168 self.stage_bar.println(warning);
169 }
170 }
171
172 fn current_ingest_file(&self) -> Option<String> {
173 let state = self.state.lock().unwrap();
174 state.last_ingest_file.clone()
175 }
176
177 fn handle_stage_started(&self, stage_name: &str) {
178 self.stage_bar.set_style(self.stage_spinner_style.clone());
179 self.stage_bar.set_message(format!("{stage_name}..."));
180 }
181
182 fn handle_stage_completed(&self, stage_name: &str, stage_duration: std::time::Duration) {
183 self.stage_bar.set_style(self.stage_spinner_style.clone());
184 self.stage_bar
185 .set_message(format!("{stage_name} completed in {stage_duration:.2?}"));
186 }
187
188 fn handle_graph_phase_started(&self, phase_number: u8, phase_name: &str, total_items: usize) {
189 if total_items == 0 {
190 self.stage_bar.set_style(self.stage_spinner_style.clone());
192 } else {
193 self.stage_bar.set_style(self.stage_bar_style.clone());
194 self.stage_bar.set_length(total_items as u64);
195 }
196 self.stage_bar.set_position(0);
197 self.stage_bar
198 .set_message(format_graph_phase_message(phase_number, phase_name));
199 }
200
201 fn handle_graph_phase_progress(&self, items_processed: usize, total_items: usize) {
202 self.stage_bar.set_position(items_processed as u64);
203 if self.stage_bar.length() != Some(total_items as u64) {
204 self.stage_bar.set_length(total_items as u64);
205 }
206 }
207
208 fn handle_graph_phase_completed(
209 &self,
210 phase_number: u8,
211 phase_name: &str,
212 phase_duration: std::time::Duration,
213 ) {
214 self.stage_bar.set_message(format!(
215 "{} completed in {phase_duration:.2?}",
216 format_graph_phase_message(phase_number, phase_name)
217 ));
218 }
219
220 fn handle_saving_started(&self, component_name: &str) {
221 self.stage_bar.set_style(self.stage_spinner_style.clone());
222 self.stage_bar
223 .set_message(format!("Saving {component_name}..."));
224 }
225
226 fn handle_saving_completed(&self, component_name: &str, save_duration: std::time::Duration) {
227 self.stage_bar
228 .set_message(format!("Saved {component_name} in {save_duration:.2?}"));
229 }
230
231 fn handle_completed(&self, total_symbols: usize, duration: std::time::Duration) {
232 self.stage_bar
233 .set_message(format!("Indexed {total_symbols} symbols in {duration:.2?}"));
234 }
235}
236
237impl ProgressReporter for CliProgressReporter {
238 fn report(&self, event: IndexProgress) {
239 match event {
240 IndexProgress::Started { total_files } => {
241 self.handle_started(total_files);
242 }
243 IndexProgress::FileProcessing {
244 path,
245 current,
246 total: _,
247 } => {
248 self.handle_file_processing(&path, current);
249 }
250 IndexProgress::FileCompleted { symbols, .. } => {
251 self.handle_file_completed(symbols);
252 }
253 IndexProgress::IngestProgress {
254 files_processed,
255 total_files,
256 total_symbols,
257 counts,
258 elapsed,
259 eta,
260 } => {
261 self.handle_ingest_progress(
262 files_processed,
263 total_files,
264 total_symbols,
265 &counts,
266 elapsed,
267 eta,
268 );
269 }
270 IndexProgress::IngestFileStarted { path, .. } => {
271 self.handle_ingest_file_started(&path);
272 }
273 IndexProgress::IngestFileCompleted {
274 path,
275 symbols,
276 duration,
277 } => {
278 self.handle_ingest_file_completed(&path, symbols, duration);
279 }
280 IndexProgress::StageStarted { stage_name } => {
281 self.handle_stage_started(stage_name);
282 }
283 IndexProgress::StageCompleted {
284 stage_name,
285 stage_duration,
286 } => {
287 self.handle_stage_completed(stage_name, stage_duration);
288 }
289 IndexProgress::GraphPhaseStarted {
291 phase_number,
292 phase_name,
293 total_items,
294 } => {
295 self.handle_graph_phase_started(phase_number, phase_name, total_items);
296 }
297 IndexProgress::GraphPhaseProgress {
298 items_processed,
299 total_items,
300 ..
301 } => {
302 self.handle_graph_phase_progress(items_processed, total_items);
303 }
304 IndexProgress::GraphPhaseCompleted {
305 phase_number,
306 phase_name,
307 phase_duration,
308 } => {
309 self.handle_graph_phase_completed(phase_number, phase_name, phase_duration);
310 }
311 IndexProgress::SavingStarted { component_name } => {
313 self.handle_saving_started(component_name);
314 }
315 IndexProgress::SavingCompleted {
316 component_name,
317 save_duration,
318 } => {
319 self.handle_saving_completed(component_name, save_duration);
320 }
321 IndexProgress::Completed {
324 total_symbols,
325 duration,
326 } => {
327 self.handle_completed(total_symbols, duration);
328 }
329 _ => {}
331 }
332 }
333}
334
335fn format_ingest_counts(counts: &NodeIngestCounts) -> String {
336 let mut parts = Vec::new();
337 parts.push(format!("fn {}", format_count(counts.functions)));
338 parts.push(format!("mth {}", format_count(counts.methods)));
339 parts.push(format!("cls {}", format_count(counts.classes)));
340 if counts.structs > 0 {
341 parts.push(format!("struct {}", format_count(counts.structs)));
342 }
343 if counts.enums > 0 {
344 parts.push(format!("enum {}", format_count(counts.enums)));
345 }
346 if counts.interfaces > 0 {
347 parts.push(format!("iface {}", format_count(counts.interfaces)));
348 }
349 if counts.other > 0 {
350 parts.push(format!("other {}", format_count(counts.other)));
351 }
352 parts.join(", ")
353}
354
355fn format_graph_phase_message(phase_number: u8, phase_name: &str) -> String {
356 if phase_number == 1
357 && phase_name == "Chunked structural indexing (parse -> range-plan -> semantic commit)"
358 {
359 return format!("Phase 1-3/{TOTAL_GRAPH_PHASES}: {phase_name}");
360 }
361 format!("Phase {phase_number}/{TOTAL_GRAPH_PHASES}: {phase_name}")
362}
363
364fn ingest_file_label(path: &Path) -> String {
365 path.file_name()
366 .and_then(|name| name.to_str())
367 .map_or_else(|| path.display().to_string(), ToString::to_string)
368}
369
370fn is_slow_ingest(duration: Duration) -> bool {
371 duration >= Duration::from_secs(SLOW_INGEST_WARNING_SECS)
372}
373
374fn format_count(value: usize) -> String {
375 if value < 1_000 {
376 return value.to_string();
377 }
378 let thousands = value / 1_000;
379 let remainder = value % 1_000;
380 if thousands < 10 {
381 let tenths = remainder / 100;
382 if tenths == 0 {
383 format!("{thousands}k")
384 } else {
385 format!("{thousands}.{tenths}k")
386 }
387 } else {
388 format!("{thousands}k")
389 }
390}
391
392fn format_rate(files_processed: usize, elapsed: std::time::Duration) -> String {
393 let elapsed_ms = elapsed.as_millis();
394 if elapsed_ms == 0 {
395 return "0 files/sec".to_string();
396 }
397 let files_processed = u128::from(files_processed as u64);
398 let rate = (files_processed * 1_000) / elapsed_ms;
399 format!("{rate} files/sec")
400}
401
402fn format_duration_clock(duration: std::time::Duration) -> String {
403 let secs = duration.as_secs();
404 let minutes = secs / 60;
405 let seconds = secs % 60;
406 if minutes < 60 {
407 return format!("{minutes:02}:{seconds:02}");
408 }
409 let hours = minutes / 60;
410 let rem_minutes = minutes % 60;
411 format!("{hours}h{rem_minutes:02}m")
412}
413
414pub struct CliStepProgressReporter {
418 state: Mutex<StepState>,
419}
420
421#[derive(Default)]
422struct StepState {
423 total_files: Option<usize>,
424}
425
426impl CliStepProgressReporter {
427 #[must_use]
428 pub fn new() -> Self {
429 Self {
430 state: Mutex::new(StepState::default()),
431 }
432 }
433}
434
435impl Default for CliStepProgressReporter {
436 fn default() -> Self {
437 Self::new()
438 }
439}
440
441impl ProgressReporter for CliStepProgressReporter {
442 fn report(&self, event: IndexProgress) {
443 match event {
444 IndexProgress::Started { total_files } => {
445 let mut state = self.state.lock().unwrap();
446 state.total_files = Some(total_files);
447 println!("Indexing {total_files} files...");
448 }
449 IndexProgress::GraphPhaseStarted {
450 phase_number,
451 phase_name,
452 total_items,
453 } => {
454 println!(
455 "{} ({total_items} items)...",
456 format_graph_phase_message(phase_number, phase_name)
457 );
458 }
459 IndexProgress::GraphPhaseCompleted {
460 phase_number,
461 phase_name,
462 phase_duration,
463 } => {
464 println!(
465 "{} completed in {phase_duration:.2?}",
466 format_graph_phase_message(phase_number, phase_name)
467 );
468 }
469 IndexProgress::IngestProgress {
470 files_processed,
471 total_files: _,
472 total_symbols,
473 counts,
474 elapsed,
475 eta,
476 } => {
477 let rate = format_rate(files_processed, elapsed);
478 let eta_display = eta.map_or_else(|| "--:--".to_string(), format_duration_clock);
479 let elapsed_display = format_duration_clock(elapsed);
480 println!(
481 "Ingesting symbols: {total_symbols} symbols | elapsed {elapsed_display} | eta {eta_display} | {rate}"
482 );
483 println!("({})", format_ingest_counts(&counts));
484 }
485 IndexProgress::IngestFileCompleted {
486 path,
487 symbols,
488 duration,
489 } => {
490 if is_slow_ingest(duration) {
491 println!(
492 "Warning: slow ingest ({duration:.2?}, {symbols} symbols): {}",
493 path.display()
494 );
495 }
496 }
497 IndexProgress::StageStarted { stage_name } => {
498 println!("Stage: {stage_name}...");
499 }
500 IndexProgress::StageCompleted {
501 stage_name,
502 stage_duration,
503 } => {
504 println!("Stage: {stage_name} completed in {stage_duration:.2?}");
505 }
506 IndexProgress::SavingStarted { component_name } => {
507 println!("Saving {component_name}...");
508 }
509 IndexProgress::SavingCompleted {
510 component_name,
511 save_duration,
512 } => {
513 println!("Saved {component_name} in {save_duration:.2?}");
514 }
515 IndexProgress::Completed {
516 total_symbols,
517 duration,
518 } => {
519 let total_files = self
520 .state
521 .lock()
522 .unwrap()
523 .total_files
524 .map_or_else(String::new, |count| format!(" across {count} files"));
525 println!("Indexed {total_symbols} symbols{total_files} in {duration:.2?}");
526 }
527 _ => {}
528 }
529 }
530}
531
532pub struct StepRunner {
534 enabled: bool,
535 step_index: usize,
536}
537
538impl StepRunner {
539 #[must_use]
540 pub fn new(enabled: bool) -> Self {
541 Self {
542 enabled,
543 step_index: 0,
544 }
545 }
546
547 pub fn step<T, E, F>(&mut self, name: &str, action: F) -> Result<T, E>
553 where
554 E: std::fmt::Display,
555 F: FnOnce() -> Result<T, E>,
556 {
557 self.step_index += 1;
558 let step_number = self.step_index;
559 if self.enabled {
560 println!("Step {step_number}: {name}...");
561 }
562 let start = Instant::now();
563 let result = action();
564 if self.enabled {
565 match &result {
566 Ok(_) => println!(
567 "Step {step_number}: {name} completed in {:.2?}",
568 start.elapsed()
569 ),
570 Err(err) => println!(
571 "Step {step_number}: {name} failed after {:.2?}: {err}",
572 start.elapsed()
573 ),
574 }
575 }
576 result
577 }
578}
579
580impl Default for CliProgressReporter {
581 fn default() -> Self {
582 Self::new()
583 }
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616enum PlainOutputMode {
617 Plain,
619 Json,
621}
622
623impl PlainOutputMode {
624 fn from_env() -> Self {
628 std::env::var("SQRY_OUTPUT_FORMAT")
629 .ok()
630 .filter(|v| v.eq_ignore_ascii_case("json"))
631 .map_or(Self::Plain, |_| Self::Json)
632 }
633}
634
635pub struct PlainProgressReporter {
640 mode: PlainOutputMode,
641 state: Mutex<PlainProgressState>,
642}
643
644#[derive(Default)]
645struct PlainProgressState {
646 last_files_emit: Option<Instant>,
649}
650
651const PLAIN_FILES_RATE_LIMIT: Duration = Duration::from_millis(250);
653
654impl PlainProgressReporter {
655 #[must_use]
657 pub fn new() -> Self {
658 Self {
659 mode: PlainOutputMode::from_env(),
660 state: Mutex::new(PlainProgressState::default()),
661 }
662 }
663
664 #[must_use]
672 pub fn for_search(verbose: bool) -> SharedReporter {
673 if verbose {
674 Arc::new(Self::new())
675 } else {
676 no_op_reporter()
677 }
678 }
679
680 fn emit_stage_started(&self, stage_name: &'static str) {
681 write_stage_started(&mut io::stderr().lock(), self.mode, stage_name);
682 }
683
684 fn emit_stage_completed(&self, stage_name: &'static str, duration: Duration) {
685 write_stage_completed(&mut io::stderr().lock(), self.mode, stage_name, duration);
686 }
687
688 fn emit_summary(&self, total_symbols: usize, duration: Duration) {
689 write_summary(&mut io::stderr().lock(), self.mode, total_symbols, duration);
690 }
691
692 fn emit_files(&self, current: usize, total: usize) {
693 write_files(&mut io::stderr().lock(), self.mode, current, total);
694 }
695}
696
697fn write_stage_started<W: IoWrite>(w: &mut W, mode: PlainOutputMode, stage_name: &'static str) {
702 match mode {
703 PlainOutputMode::Plain => {
704 let _ = writeln!(w, "[sqry] {stage_name} ...");
705 }
706 PlainOutputMode::Json => {
707 write_json(
708 w,
709 &[
710 ("event", JsonValue::Str("stage_started")),
711 ("stage", JsonValue::Str(stage_name)),
712 ("ts", JsonValue::Num(unix_millis())),
713 ],
714 );
715 }
716 }
717}
718
719fn write_stage_completed<W: IoWrite>(
720 w: &mut W,
721 mode: PlainOutputMode,
722 stage_name: &'static str,
723 duration: Duration,
724) {
725 match mode {
726 PlainOutputMode::Plain => {
727 let _ = writeln!(
728 w,
729 "[sqry] {stage_name} complete in {}",
730 format_brief_duration(duration)
731 );
732 }
733 PlainOutputMode::Json => {
734 let ms = u128_to_u64_saturating(duration.as_millis());
735 write_json(
736 w,
737 &[
738 ("event", JsonValue::Str("stage_completed")),
739 ("stage", JsonValue::Str(stage_name)),
740 ("duration_ms", JsonValue::Num(ms)),
741 ("ts", JsonValue::Num(unix_millis())),
742 ],
743 );
744 }
745 }
746}
747
748fn write_summary<W: IoWrite>(
749 w: &mut W,
750 mode: PlainOutputMode,
751 total_symbols: usize,
752 duration: Duration,
753) {
754 match mode {
755 PlainOutputMode::Plain => {
756 let _ = writeln!(
757 w,
758 "[sqry] indexing complete: {total_symbols} symbols in {}",
759 format_brief_duration(duration)
760 );
761 }
762 PlainOutputMode::Json => {
763 let ms = u128_to_u64_saturating(duration.as_millis());
764 write_json(
765 w,
766 &[
767 ("event", JsonValue::Str("completed")),
768 ("total_symbols", JsonValue::Num(total_symbols as u64)),
769 ("duration_ms", JsonValue::Num(ms)),
770 ("ts", JsonValue::Num(unix_millis())),
771 ],
772 );
773 }
774 }
775}
776
777fn write_files<W: IoWrite>(w: &mut W, mode: PlainOutputMode, current: usize, total: usize) {
778 match mode {
779 PlainOutputMode::Plain => {
780 let _ = writeln!(w, "[sqry] files processed {current}/{total}");
781 }
782 PlainOutputMode::Json => {
783 write_json(
784 w,
785 &[
786 ("event", JsonValue::Str("files_progress")),
787 ("current", JsonValue::Num(current as u64)),
788 ("total", JsonValue::Num(total as u64)),
789 ("ts", JsonValue::Num(unix_millis())),
790 ],
791 );
792 }
793 }
794}
795
796fn write_json<W: IoWrite>(w: &mut W, fields: &[(&str, JsonValue)]) {
802 let _ = w.write_all(b"{");
803 for (i, (key, value)) in fields.iter().enumerate() {
804 if i > 0 {
805 let _ = w.write_all(b",");
806 }
807 debug_assert!(
808 !key.contains('"') && !key.contains('\\'),
809 "json key must not need escaping: {key}"
810 );
811 let _ = write!(w, "\"{key}\":");
812 match value {
813 JsonValue::Str(s) => {
814 debug_assert!(
815 !s.contains('"') && !s.contains('\\'),
816 "json string value must not need escaping: {s}"
817 );
818 let _ = write!(w, "\"{s}\"");
819 }
820 JsonValue::Num(n) => {
821 let _ = write!(w, "{n}");
822 }
823 }
824 }
825 let _ = w.write_all(b"}\n");
826}
827
828impl Default for PlainProgressReporter {
829 fn default() -> Self {
830 Self::new()
831 }
832}
833
834enum JsonValue {
836 Str(&'static str),
837 Num(u64),
838}
839
840impl ProgressReporter for PlainProgressReporter {
841 fn report(&self, event: IndexProgress) {
842 match event {
843 IndexProgress::StageStarted { stage_name } => {
844 self.emit_stage_started(stage_name);
845 }
846 IndexProgress::StageCompleted {
847 stage_name,
848 stage_duration,
849 } => {
850 self.emit_stage_completed(stage_name, stage_duration);
851 }
852 IndexProgress::FileProcessing { current, total, .. } => {
853 let should_emit = {
857 let mut state = match self.state.lock() {
858 Ok(g) => g,
859 Err(poisoned) => poisoned.into_inner(),
863 };
864 let now = Instant::now();
865 let allow = state
866 .last_files_emit
867 .is_none_or(|t| now.duration_since(t) >= PLAIN_FILES_RATE_LIMIT);
868 if allow {
869 state.last_files_emit = Some(now);
870 }
871 allow
872 };
873 if should_emit {
874 self.emit_files(current, total);
875 }
876 }
877 IndexProgress::Completed {
878 total_symbols,
879 duration,
880 } => {
881 self.emit_summary(total_symbols, duration);
882 }
883 _ => {}
889 }
890 }
891}
892
893fn format_brief_duration(d: Duration) -> String {
896 let secs = d.as_secs_f64();
897 if secs >= 1.0 {
898 format!("{secs:.2}s")
899 } else if d.as_millis() >= 1 {
900 format!("{}ms", d.as_millis())
901 } else {
902 format!("{}us", d.as_micros())
903 }
904}
905
906fn unix_millis() -> u64 {
910 SystemTime::now()
911 .duration_since(UNIX_EPOCH)
912 .map(|d| u128_to_u64_saturating(d.as_millis()))
913 .unwrap_or(0)
914}
915
916fn u128_to_u64_saturating(v: u128) -> u64 {
920 if v > u64::MAX as u128 {
921 u64::MAX
922 } else {
923 v as u64
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::{format_duration_clock, format_graph_phase_message, format_rate};
930 use std::time::Duration;
931
932 #[test]
933 fn test_format_rate_zero_elapsed() {
934 assert_eq!(format_rate(0, Duration::from_secs(0)), "0 files/sec");
935 }
936
937 #[test]
938 fn test_format_rate_per_second() {
939 assert_eq!(format_rate(1000, Duration::from_secs(1)), "1000 files/sec");
940 }
941
942 #[test]
943 fn test_format_rate_fractional_seconds() {
944 assert_eq!(format_rate(1500, Duration::from_secs(2)), "750 files/sec");
945 }
946
947 #[test]
948 fn test_format_duration_clock_under_hour() {
949 assert_eq!(format_duration_clock(Duration::from_secs(65)), "01:05");
950 }
951
952 #[test]
953 fn test_format_duration_clock_hour_boundary() {
954 assert_eq!(format_duration_clock(Duration::from_secs(3600)), "1h00m");
955 }
956
957 #[test]
958 fn test_format_duration_clock_hours_minutes() {
959 assert_eq!(format_duration_clock(Duration::from_secs(3720)), "1h02m");
960 }
961
962 #[test]
963 fn test_format_graph_phase_message() {
964 assert_eq!(
965 format_graph_phase_message(
966 1,
967 "Chunked structural indexing (parse -> range-plan -> semantic commit)"
968 ),
969 "Phase 1-3/8: Chunked structural indexing (parse -> range-plan -> semantic commit)"
970 );
971 }
972}
973
974#[cfg(test)]
975mod plain_reporter_tests {
976 use super::{
977 PLAIN_FILES_RATE_LIMIT, PlainOutputMode, PlainProgressReporter, format_brief_duration,
978 write_files, write_stage_completed, write_stage_started, write_summary,
979 };
980 use sqry_core::progress::{IndexProgress, ProgressReporter};
981 use std::sync::Arc;
982 use std::thread;
983 use std::time::Duration;
984
985 fn captured<F: FnOnce(&mut Vec<u8>)>(f: F) -> String {
986 let mut buf = Vec::new();
987 f(&mut buf);
988 String::from_utf8(buf).expect("plain-reporter output must be valid utf8")
989 }
990
991 #[test]
994 fn format_brief_duration_sub_millis_uses_us() {
995 let s = format_brief_duration(Duration::from_micros(42));
996 assert_eq!(s, "42us");
997 }
998
999 #[test]
1000 fn format_brief_duration_sub_second_uses_ms() {
1001 let s = format_brief_duration(Duration::from_millis(150));
1002 assert_eq!(s, "150ms");
1003 }
1004
1005 #[test]
1006 fn format_brief_duration_super_second_uses_two_decimals() {
1007 let s = format_brief_duration(Duration::from_millis(1240));
1008 assert_eq!(s, "1.24s");
1009 }
1010
1011 fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
1019 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1024 let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
1025 let prev = std::env::var(key).ok();
1026 unsafe {
1028 if let Some(v) = value {
1029 std::env::set_var(key, v);
1030 } else {
1031 std::env::remove_var(key);
1032 }
1033 }
1034 f();
1035 unsafe {
1037 match prev {
1038 Some(v) => std::env::set_var(key, v),
1039 None => std::env::remove_var(key),
1040 }
1041 }
1042 }
1043
1044 #[test]
1045 fn output_mode_default_is_plain_when_env_unset() {
1046 with_env("SQRY_OUTPUT_FORMAT", None, || {
1047 assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Plain);
1048 });
1049 }
1050
1051 #[test]
1052 fn output_mode_json_when_env_eq_json() {
1053 with_env("SQRY_OUTPUT_FORMAT", Some("json"), || {
1054 assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Json);
1055 });
1056 }
1057
1058 #[test]
1059 fn output_mode_json_is_case_insensitive() {
1060 with_env("SQRY_OUTPUT_FORMAT", Some("JSON"), || {
1061 assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Json);
1062 });
1063 with_env("SQRY_OUTPUT_FORMAT", Some("Json"), || {
1064 assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Json);
1065 });
1066 }
1067
1068 #[test]
1069 fn output_mode_plain_when_env_unrecognised() {
1070 with_env("SQRY_OUTPUT_FORMAT", Some("yaml"), || {
1071 assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Plain);
1072 });
1073 with_env("SQRY_OUTPUT_FORMAT", Some(""), || {
1074 assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Plain);
1075 });
1076 }
1077
1078 #[test]
1081 fn for_search_false_returns_silent_reporter() {
1082 let reporter = PlainProgressReporter::for_search(false);
1083 reporter.report(IndexProgress::StageStarted {
1087 stage_name: "test stage",
1088 });
1089 }
1092
1093 #[test]
1094 fn for_search_true_returns_plain_reporter() {
1095 let reporter = PlainProgressReporter::for_search(true);
1096 reporter.report(IndexProgress::StageStarted {
1100 stage_name: "test stage",
1101 });
1102 reporter.report(IndexProgress::StageCompleted {
1103 stage_name: "test stage",
1104 stage_duration: Duration::from_millis(5),
1105 });
1106 }
1107
1108 #[test]
1111 fn plain_stage_started_format() {
1112 let out = captured(|w| write_stage_started(w, PlainOutputMode::Plain, "load snapshot"));
1113 assert_eq!(out, "[sqry] load snapshot ...\n");
1114 }
1115
1116 #[test]
1117 fn plain_stage_completed_format() {
1118 let out = captured(|w| {
1119 write_stage_completed(
1120 w,
1121 PlainOutputMode::Plain,
1122 "load snapshot",
1123 Duration::from_millis(150),
1124 );
1125 });
1126 assert_eq!(out, "[sqry] load snapshot complete in 150ms\n");
1127 }
1128
1129 #[test]
1130 fn plain_summary_format() {
1131 let out = captured(|w| {
1132 write_summary(w, PlainOutputMode::Plain, 12345, Duration::from_millis(890));
1133 });
1134 assert_eq!(out, "[sqry] indexing complete: 12345 symbols in 890ms\n");
1135 }
1136
1137 #[test]
1138 fn plain_files_format() {
1139 let out = captured(|w| write_files(w, PlainOutputMode::Plain, 5, 100));
1140 assert_eq!(out, "[sqry] files processed 5/100\n");
1141 }
1142
1143 #[test]
1144 fn plain_output_contains_no_ansi_escape_sequences() {
1145 let buf = captured(|w| {
1146 write_stage_started(w, PlainOutputMode::Plain, "load snapshot");
1147 write_stage_completed(
1148 w,
1149 PlainOutputMode::Plain,
1150 "load snapshot",
1151 Duration::from_millis(150),
1152 );
1153 write_files(w, PlainOutputMode::Plain, 1, 2);
1154 write_summary(w, PlainOutputMode::Plain, 10, Duration::from_secs(1));
1155 });
1156 assert!(
1158 !buf.contains('\x1b'),
1159 "plain mode emitted an ANSI escape sequence: {buf:?}"
1160 );
1161 }
1162
1163 fn parse_jsonl_line(line: &str) -> serde_json::Value {
1166 serde_json::from_str(line).expect("each json-line must parse as a JSON object")
1167 }
1168
1169 #[test]
1170 fn json_stage_started_has_required_fields() {
1171 let out = captured(|w| write_stage_started(w, PlainOutputMode::Json, "exact name lookup"));
1172 assert!(out.ends_with('\n'), "json line must be newline-terminated");
1173 let v = parse_jsonl_line(out.trim_end());
1174 assert_eq!(v["event"], "stage_started");
1175 assert_eq!(v["stage"], "exact name lookup");
1176 assert!(v["ts"].is_number(), "ts must be a number");
1177 }
1178
1179 #[test]
1180 fn json_stage_completed_has_duration_ms() {
1181 let out = captured(|w| {
1182 write_stage_completed(
1183 w,
1184 PlainOutputMode::Json,
1185 "load snapshot",
1186 Duration::from_millis(42),
1187 );
1188 });
1189 let v = parse_jsonl_line(out.trim_end());
1190 assert_eq!(v["event"], "stage_completed");
1191 assert_eq!(v["stage"], "load snapshot");
1192 assert_eq!(v["duration_ms"], 42);
1193 }
1194
1195 #[test]
1196 fn json_files_progress_has_current_and_total() {
1197 let out = captured(|w| write_files(w, PlainOutputMode::Json, 7, 99));
1198 let v = parse_jsonl_line(out.trim_end());
1199 assert_eq!(v["event"], "files_progress");
1200 assert_eq!(v["current"], 7);
1201 assert_eq!(v["total"], 99);
1202 }
1203
1204 #[test]
1205 fn json_summary_has_total_symbols() {
1206 let out = captured(|w| {
1207 write_summary(w, PlainOutputMode::Json, 3, Duration::from_millis(8));
1208 });
1209 let v = parse_jsonl_line(out.trim_end());
1210 assert_eq!(v["event"], "completed");
1211 assert_eq!(v["total_symbols"], 3);
1212 assert_eq!(v["duration_ms"], 8);
1213 }
1214
1215 #[test]
1216 fn json_mode_emits_one_object_per_line() {
1217 let buf = captured(|w| {
1218 write_stage_started(w, PlainOutputMode::Json, "load snapshot");
1219 write_stage_completed(
1220 w,
1221 PlainOutputMode::Json,
1222 "load snapshot",
1223 Duration::from_millis(5),
1224 );
1225 write_stage_started(w, PlainOutputMode::Json, "exact name lookup");
1226 });
1227 let lines: Vec<&str> = buf.lines().collect();
1228 assert_eq!(lines.len(), 3, "expected exactly three JSON lines");
1229 for line in lines {
1230 let _ = parse_jsonl_line(line);
1231 }
1232 }
1233
1234 #[test]
1237 fn file_processing_rate_limit_is_250ms() {
1238 assert_eq!(PLAIN_FILES_RATE_LIMIT, Duration::from_millis(250));
1242 }
1243
1244 #[test]
1245 fn report_does_not_panic_on_event_flood() {
1246 let reporter = Arc::new(PlainProgressReporter::new());
1251 for i in 0..50_000 {
1252 reporter.report(IndexProgress::StageStarted {
1253 stage_name: "stress",
1254 });
1255 reporter.report(IndexProgress::FileProcessing {
1256 path: std::path::PathBuf::from("/tmp/stress"),
1257 current: i,
1258 total: 50_000,
1259 });
1260 }
1261 }
1262
1263 #[test]
1264 fn report_is_thread_safe_under_concurrent_emitters() {
1265 let reporter = Arc::new(PlainProgressReporter::new());
1268 let handles: Vec<_> = (0..8)
1269 .map(|t| {
1270 let r = Arc::clone(&reporter);
1271 thread::spawn(move || {
1272 for i in 0..1_000 {
1273 r.report(IndexProgress::StageStarted {
1274 stage_name: "concurrent",
1275 });
1276 r.report(IndexProgress::FileProcessing {
1277 path: std::path::PathBuf::from(format!("/tmp/{t}/{i}")),
1278 current: i,
1279 total: 1_000,
1280 });
1281 }
1282 })
1283 })
1284 .collect();
1285 for h in handles {
1286 h.join().expect("worker thread must not panic");
1287 }
1288 }
1289}