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::{
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;
14// Total number of distinct tracker phases the unified graph build emits
15// via `BuildProgressTracker::start_phase`. As of Phase A + Go T1 these are:
16//   1-3 : Chunked structural indexing (parse / range-plan / semantic commit)
17//   4   : Finalizing graph
18//   5   : Binding plane derivation (Phase 4e)
19//   6   : C indirect-call resolution (Phase A pass 5b)
20//   7   : Go method-set satisfaction (T1.2 / T1.1 / T1.3)
21//   8   : Cross-language linking (Pass 5)
22// The denominator is purely cosmetic — it shapes the "Phase N/{total}"
23// label in the CLI progress reporter. Bump it whenever a new tracker
24// phase is added or removed in `sqry-core/src/graph/unified/build/entrypoint.rs`.
25const TOTAL_GRAPH_PHASES: u8 = 8;
26
27/// CLI progress reporter using indicatif
28pub 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    /// Create a new CLI progress reporter
47    ///
48    /// # Panics
49    /// Panics if the progress bar template string is invalid.
50    #[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    /// Finish and clear the progress bar
84    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            // Use spinner style when total is unknown/zero to avoid stuck "0/0" display
191            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            // Graph build phase events
290            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            // Saving events
312            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            // Final completion - update message but don't finish the bar
322            // The bar is finished explicitly via finish() method after all phases complete
323            IndexProgress::Completed {
324                total_symbols,
325                duration,
326            } => {
327                self.handle_completed(total_symbols, duration);
328            }
329            // Handle any future variants gracefully
330            _ => {}
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
414/// Step-level progress reporter for non-TTY output.
415///
416/// Emits coarse-grained progress messages without spamming.
417pub 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
532/// Step runner for coarse-grained progress reporting.
533pub 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    /// Run a named step and emit start/finish lines when enabled.
548    ///
549    /// # Errors
550    ///
551    /// Returns any error produced by the step action.
552    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// ============================================================================
587// PlainProgressReporter — `sqry search --verbose` sibling
588// ============================================================================
589//
590// Coexists with `CliProgressReporter` (line 14, indicatif-backed, used by
591// `sqry index`) and `CliStepProgressReporter` (line 403, step-style, also used
592// by indexing). The search path needs a third reporter because indicatif's
593// `MultiProgress` writes ANSI escape sequences and redrawing widgets — fine
594// for an interactive TTY, wrong for `sqry search ... 2>&1 | grep` or scripted
595// invocations.
596//
597// Output goes to stderr exclusively (so stdout pipelines stay clean). Two
598// modes, selected by the `SQRY_OUTPUT_FORMAT` env var at construction:
599//   - default ("plain" or unset): `[sqry] <stage> ...` / `[sqry] <stage>
600//     complete in <X.YZms>` lines.
601//   - "json": one JSON object per line, e.g.
602//     `{"event":"stage_started","stage":"load snapshot","ts":1715472000123}`.
603//
604// `FileProcessing` events are rate-limited to one line per 250ms minimum so a
605// 12M-symbol indexing run can't flood stderr.
606//
607// Contract notes:
608//   - No ANSI escape sequences ever (CLI_PROGRESS_REPORTER acceptance #11).
609//   - Stable stage-name strings — JSON consumers will key off them; renames
610//     are a breaking change.
611//   - `sqry index` golden output is byte-identical because nothing in this
612//     impl block touches `CliProgressReporter` or `CliStepProgressReporter`.
613
614/// Output format for [`PlainProgressReporter`].
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616enum PlainOutputMode {
617    /// `[sqry] <stage> ...` human-readable lines (default).
618    Plain,
619    /// One JSON object per line (`SQRY_OUTPUT_FORMAT=json`).
620    Json,
621}
622
623impl PlainOutputMode {
624    /// Read `SQRY_OUTPUT_FORMAT` once at construction. Anything other than
625    /// `json` (case-insensitive) — including unset, empty, or "plain" —
626    /// resolves to `Plain`.
627    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
635/// Plain-text / JSON-line progress reporter for the `sqry search` path.
636///
637/// See module-level comment block above the type for the surface-ownership
638/// rationale and output-format contract.
639pub struct PlainProgressReporter {
640    mode: PlainOutputMode,
641    state: Mutex<PlainProgressState>,
642}
643
644#[derive(Default)]
645struct PlainProgressState {
646    /// Last time a `FileProcessing` line was emitted; used to enforce the
647    /// 250ms minimum interval. `None` means none emitted yet.
648    last_files_emit: Option<Instant>,
649}
650
651/// Minimum interval between successive `FileProcessing` emissions.
652const PLAIN_FILES_RATE_LIMIT: Duration = Duration::from_millis(250);
653
654impl PlainProgressReporter {
655    /// Construct a reporter wired to the current `SQRY_OUTPUT_FORMAT` env.
656    #[must_use]
657    pub fn new() -> Self {
658        Self {
659            mode: PlainOutputMode::from_env(),
660            state: Mutex::new(PlainProgressState::default()),
661        }
662    }
663
664    /// Returns an `Arc<PlainProgressReporter>` when `verbose` is true,
665    /// otherwise the canonical `no_op_reporter()` (so callers can use the
666    /// same `SharedReporter` type regardless of opt-in state).
667    ///
668    /// Named `for_search` (not `for_cli`) to disambiguate against the
669    /// existing `CliProgressReporter` (which is also a "CLI" reporter) and
670    /// against `commands::graph::loader::load_unified_graph_for_cli`.
671    #[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
697// Free-standing writers parameterised over `W: Write` so unit tests can
698// drive them against an in-memory buffer. The `impl ProgressReporter` path
699// above locks stderr and delegates here; tests use a `Vec<u8>`.
700
701fn 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
796/// Emit a JSON object with the given key/value pairs, terminated by `\n`.
797/// Hand-rolled (no `serde_json::Value` allocation) so the hot path stays
798/// allocation-light. All values are owned by the caller as `&'static str`
799/// or `u64`; debug-asserts guard against meta characters that would require
800/// escaping.
801fn 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
834/// Minimal JSON value type for the hand-rolled writer in `emit_json`.
835enum 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                // Rate-limit to one line per 250ms. Hold the mutex only long
854                // enough to read/update the timestamp — emission happens
855                // after release so writers don't serialise on this lock.
856                let should_emit = {
857                    let mut state = match self.state.lock() {
858                        Ok(g) => g,
859                        // Mutex is poisoned: a previous emitter panicked.
860                        // Recover the inner state (the only invariant is the
861                        // timestamp, which is harmless to reset) and continue.
862                        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            // Other events (FileCompleted, IngestProgress, IngestFile*,
884            // GraphPhase*, Saving*) are intentionally not emitted — they
885            // are indexing-internal and the search path doesn't trigger
886            // them with enough density to matter. `non_exhaustive` on the
887            // enum requires a wildcard.
888            _ => {}
889        }
890    }
891}
892
893/// Format a `Duration` compactly: `<1ms` shows microseconds; `<1s` shows
894/// milliseconds; otherwise shows seconds with two decimals.
895fn 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
906/// Best-effort millisecond unix timestamp for JSON-line events. Returns 0 if
907/// the system clock is before the epoch (cannot happen in practice but
908/// avoids a panic on a malformed clock).
909fn 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
916/// Convert `u128` to `u64` saturating at `u64::MAX`. Duration::as_millis()
917/// returns u128 but we serialize as u64 — a u64 millis count is good for
918/// ~584 million years, so this is safety more than necessity.
919fn 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    // ── format_brief_duration ────────────────────────────────────────
992
993    #[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    // ── PlainOutputMode::from_env ────────────────────────────────────
1012    //
1013    // These tests serialize on a process-global env var, so they share a
1014    // mutex to avoid race-induced flakes. We DO NOT use #[serial_test]
1015    // because the test crate doesn't depend on it; a local Mutex is
1016    // sufficient because the lock scope is small.
1017
1018    fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
1019        // SAFETY: tests within this module take the SAME guard before
1020        // touching env, so there is no cross-test racing inside the crate.
1021        // Outside the crate, set/remove_var is unsafe in Rust 2024; the
1022        // mutex confines the unsafe to within-crate test runs.
1023        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        // SAFETY: see above — tests serialize on LOCK.
1027        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        // SAFETY: restoring previous state under the same lock.
1036        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    // ── for_search constructor ───────────────────────────────────────
1079
1080    #[test]
1081    fn for_search_false_returns_silent_reporter() {
1082        let reporter = PlainProgressReporter::for_search(false);
1083        // The no_op reporter must accept events without writing anything;
1084        // we exercise it through the trait and assume sqry_core's
1085        // no_op_reporter contract holds.
1086        reporter.report(IndexProgress::StageStarted {
1087            stage_name: "test stage",
1088        });
1089        // No assertion needed — the contract is "must not panic, must not
1090        // write." The latter is enforced by no_op_reporter itself.
1091    }
1092
1093    #[test]
1094    fn for_search_true_returns_plain_reporter() {
1095        let reporter = PlainProgressReporter::for_search(true);
1096        // Cannot downcast through SharedReporter without RTTI infrastructure;
1097        // instead, verify the contract behavior (events do not panic) and
1098        // rely on integration tests for stderr capture.
1099        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    // ── Plain-mode line format ───────────────────────────────────────
1109
1110    #[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        // Acceptance #11: no ANSI escape sequences.
1157        assert!(
1158            !buf.contains('\x1b'),
1159            "plain mode emitted an ANSI escape sequence: {buf:?}"
1160        );
1161    }
1162
1163    // ── JSON-mode line format ────────────────────────────────────────
1164
1165    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    // ── Rate limiting + stress ───────────────────────────────────────
1235
1236    #[test]
1237    fn file_processing_rate_limit_is_250ms() {
1238        // The rate limit is the constant — verify the constant itself so a
1239        // future refactor that loosens or tightens it is forced to update
1240        // this test alongside the contract docstring.
1241        assert_eq!(PLAIN_FILES_RATE_LIMIT, Duration::from_millis(250));
1242    }
1243
1244    #[test]
1245    fn report_does_not_panic_on_event_flood() {
1246        // Acceptance #9: no panics on event flood (10k+ events/sec).
1247        // We approximate "10k events/sec" with 50k synchronous events; if
1248        // the reporter is non-allocating in the hot path this completes in
1249        // well under a second on any CI runner.
1250        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        // ProgressReporter requires Send + Sync; exercise it from multiple
1266        // threads at once. The reporter must not deadlock or panic.
1267        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}