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;
14const TOTAL_GRAPH_PHASES: u8 = 5;
15
16/// CLI progress reporter using indicatif
17pub 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    /// Create a new CLI progress reporter
36    ///
37    /// # Panics
38    /// Panics if the progress bar template string is invalid.
39    #[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    /// Finish and clear the progress bar
73    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            // Use spinner style when total is unknown/zero to avoid stuck "0/0" display
180            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            // Graph build phase events
279            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            // Saving events
301            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            // Final completion - update message but don't finish the bar
311            // The bar is finished explicitly via finish() method after all phases complete
312            IndexProgress::Completed {
313                total_symbols,
314                duration,
315            } => {
316                self.handle_completed(total_symbols, duration);
317            }
318            // Handle any future variants gracefully
319            _ => {}
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
403/// Step-level progress reporter for non-TTY output.
404///
405/// Emits coarse-grained progress messages without spamming.
406pub 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
521/// Step runner for coarse-grained progress reporting.
522pub 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    /// Run a named step and emit start/finish lines when enabled.
537    ///
538    /// # Errors
539    ///
540    /// Returns any error produced by the step action.
541    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// ============================================================================
576// PlainProgressReporter — `sqry search --verbose` sibling
577// ============================================================================
578//
579// Coexists with `CliProgressReporter` (line 14, indicatif-backed, used by
580// `sqry index`) and `CliStepProgressReporter` (line 403, step-style, also used
581// by indexing). The search path needs a third reporter because indicatif's
582// `MultiProgress` writes ANSI escape sequences and redrawing widgets — fine
583// for an interactive TTY, wrong for `sqry search ... 2>&1 | grep` or scripted
584// invocations.
585//
586// Output goes to stderr exclusively (so stdout pipelines stay clean). Two
587// modes, selected by the `SQRY_OUTPUT_FORMAT` env var at construction:
588//   - default ("plain" or unset): `[sqry] <stage> ...` / `[sqry] <stage>
589//     complete in <X.YZms>` lines.
590//   - "json": one JSON object per line, e.g.
591//     `{"event":"stage_started","stage":"load snapshot","ts":1715472000123}`.
592//
593// `FileProcessing` events are rate-limited to one line per 250ms minimum so a
594// 12M-symbol indexing run can't flood stderr.
595//
596// Contract notes:
597//   - No ANSI escape sequences ever (CLI_PROGRESS_REPORTER acceptance #11).
598//   - Stable stage-name strings — JSON consumers will key off them; renames
599//     are a breaking change.
600//   - `sqry index` golden output is byte-identical because nothing in this
601//     impl block touches `CliProgressReporter` or `CliStepProgressReporter`.
602
603/// Output format for [`PlainProgressReporter`].
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605enum PlainOutputMode {
606    /// `[sqry] <stage> ...` human-readable lines (default).
607    Plain,
608    /// One JSON object per line (`SQRY_OUTPUT_FORMAT=json`).
609    Json,
610}
611
612impl PlainOutputMode {
613    /// Read `SQRY_OUTPUT_FORMAT` once at construction. Anything other than
614    /// `json` (case-insensitive) — including unset, empty, or "plain" —
615    /// resolves to `Plain`.
616    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
624/// Plain-text / JSON-line progress reporter for the `sqry search` path.
625///
626/// See module-level comment block above the type for the surface-ownership
627/// rationale and output-format contract.
628pub struct PlainProgressReporter {
629    mode: PlainOutputMode,
630    state: Mutex<PlainProgressState>,
631}
632
633#[derive(Default)]
634struct PlainProgressState {
635    /// Last time a `FileProcessing` line was emitted; used to enforce the
636    /// 250ms minimum interval. `None` means none emitted yet.
637    last_files_emit: Option<Instant>,
638}
639
640/// Minimum interval between successive `FileProcessing` emissions.
641const PLAIN_FILES_RATE_LIMIT: Duration = Duration::from_millis(250);
642
643impl PlainProgressReporter {
644    /// Construct a reporter wired to the current `SQRY_OUTPUT_FORMAT` env.
645    #[must_use]
646    pub fn new() -> Self {
647        Self {
648            mode: PlainOutputMode::from_env(),
649            state: Mutex::new(PlainProgressState::default()),
650        }
651    }
652
653    /// Returns an `Arc<PlainProgressReporter>` when `verbose` is true,
654    /// otherwise the canonical `no_op_reporter()` (so callers can use the
655    /// same `SharedReporter` type regardless of opt-in state).
656    ///
657    /// Named `for_search` (not `for_cli`) to disambiguate against the
658    /// existing `CliProgressReporter` (which is also a "CLI" reporter) and
659    /// against `commands::graph::loader::load_unified_graph_for_cli`.
660    #[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
686// Free-standing writers parameterised over `W: Write` so unit tests can
687// drive them against an in-memory buffer. The `impl ProgressReporter` path
688// above locks stderr and delegates here; tests use a `Vec<u8>`.
689
690fn 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
785/// Emit a JSON object with the given key/value pairs, terminated by `\n`.
786/// Hand-rolled (no `serde_json::Value` allocation) so the hot path stays
787/// allocation-light. All values are owned by the caller as `&'static str`
788/// or `u64`; debug-asserts guard against meta characters that would require
789/// escaping.
790fn 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
823/// Minimal JSON value type for the hand-rolled writer in `emit_json`.
824enum 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                // Rate-limit to one line per 250ms. Hold the mutex only long
843                // enough to read/update the timestamp — emission happens
844                // after release so writers don't serialise on this lock.
845                let should_emit = {
846                    let mut state = match self.state.lock() {
847                        Ok(g) => g,
848                        // Mutex is poisoned: a previous emitter panicked.
849                        // Recover the inner state (the only invariant is the
850                        // timestamp, which is harmless to reset) and continue.
851                        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            // Other events (FileCompleted, IngestProgress, IngestFile*,
873            // GraphPhase*, Saving*) are intentionally not emitted — they
874            // are indexing-internal and the search path doesn't trigger
875            // them with enough density to matter. `non_exhaustive` on the
876            // enum requires a wildcard.
877            _ => {}
878        }
879    }
880}
881
882/// Format a `Duration` compactly: `<1ms` shows microseconds; `<1s` shows
883/// milliseconds; otherwise shows seconds with two decimals.
884fn 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
895/// Best-effort millisecond unix timestamp for JSON-line events. Returns 0 if
896/// the system clock is before the epoch (cannot happen in practice but
897/// avoids a panic on a malformed clock).
898fn 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
905/// Convert `u128` to `u64` saturating at `u64::MAX`. Duration::as_millis()
906/// returns u128 but we serialize as u64 — a u64 millis count is good for
907/// ~584 million years, so this is safety more than necessity.
908fn 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    // ── format_brief_duration ────────────────────────────────────────
981
982    #[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    // ── PlainOutputMode::from_env ────────────────────────────────────
1001    //
1002    // These tests serialize on a process-global env var, so they share a
1003    // mutex to avoid race-induced flakes. We DO NOT use #[serial_test]
1004    // because the test crate doesn't depend on it; a local Mutex is
1005    // sufficient because the lock scope is small.
1006
1007    fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
1008        // SAFETY: tests within this module take the SAME guard before
1009        // touching env, so there is no cross-test racing inside the crate.
1010        // Outside the crate, set/remove_var is unsafe in Rust 2024; the
1011        // mutex confines the unsafe to within-crate test runs.
1012        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        // SAFETY: see above — tests serialize on LOCK.
1016        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        // SAFETY: restoring previous state under the same lock.
1025        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    // ── for_search constructor ───────────────────────────────────────
1068
1069    #[test]
1070    fn for_search_false_returns_silent_reporter() {
1071        let reporter = PlainProgressReporter::for_search(false);
1072        // The no_op reporter must accept events without writing anything;
1073        // we exercise it through the trait and assume sqry_core's
1074        // no_op_reporter contract holds.
1075        reporter.report(IndexProgress::StageStarted {
1076            stage_name: "test stage",
1077        });
1078        // No assertion needed — the contract is "must not panic, must not
1079        // write." The latter is enforced by no_op_reporter itself.
1080    }
1081
1082    #[test]
1083    fn for_search_true_returns_plain_reporter() {
1084        let reporter = PlainProgressReporter::for_search(true);
1085        // Cannot downcast through SharedReporter without RTTI infrastructure;
1086        // instead, verify the contract behavior (events do not panic) and
1087        // rely on integration tests for stderr capture.
1088        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    // ── Plain-mode line format ───────────────────────────────────────
1098
1099    #[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        // Acceptance #11: no ANSI escape sequences.
1146        assert!(
1147            !buf.contains('\x1b'),
1148            "plain mode emitted an ANSI escape sequence: {buf:?}"
1149        );
1150    }
1151
1152    // ── JSON-mode line format ────────────────────────────────────────
1153
1154    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    // ── Rate limiting + stress ───────────────────────────────────────
1224
1225    #[test]
1226    fn file_processing_rate_limit_is_250ms() {
1227        // The rate limit is the constant — verify the constant itself so a
1228        // future refactor that loosens or tightens it is forced to update
1229        // this test alongside the contract docstring.
1230        assert_eq!(PLAIN_FILES_RATE_LIMIT, Duration::from_millis(250));
1231    }
1232
1233    #[test]
1234    fn report_does_not_panic_on_event_flood() {
1235        // Acceptance #9: no panics on event flood (10k+ events/sec).
1236        // We approximate "10k events/sec" with 50k synchronous events; if
1237        // the reporter is non-allocating in the hot path this completes in
1238        // well under a second on any CI runner.
1239        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        // ProgressReporter requires Send + Sync; exercise it from multiple
1255        // threads at once. The reporter must not deadlock or panic.
1256        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}