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