use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use sqry_core::progress::{
IndexProgress, NodeIngestCounts, ProgressReporter, SharedReporter, no_op_reporter,
};
use std::fmt::Write;
use std::io::{self, Write as IoWrite};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const SLOW_INGEST_WARNING_SECS: u64 = 3;
const TOTAL_GRAPH_PHASES: u8 = 5;
pub struct CliProgressReporter {
multi: MultiProgress,
file_bar: ProgressBar,
stage_bar: ProgressBar,
file_style: ProgressStyle,
stage_bar_style: ProgressStyle,
stage_spinner_style: ProgressStyle,
state: Mutex<CliProgressState>,
}
#[derive(Default)]
struct CliProgressState {
total_files: Option<usize>,
file_bar_finished: bool,
last_ingest_file: Option<String>,
}
impl CliProgressReporter {
#[must_use]
pub fn new() -> Self {
let multi = MultiProgress::new();
let file_bar = multi.add(ProgressBar::new(0));
let stage_bar = multi.add(ProgressBar::new_spinner());
let file_style = ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} files | {msg}")
.unwrap()
.progress_chars("=>-");
let stage_bar_style = ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | {msg}")
.unwrap()
.progress_chars("=>-");
let stage_spinner_style = ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.unwrap();
file_bar.set_style(file_style.clone());
stage_bar.set_style(stage_spinner_style.clone());
stage_bar.enable_steady_tick(std::time::Duration::from_millis(120));
Self {
multi,
file_bar,
stage_bar,
file_style,
stage_bar_style,
stage_spinner_style,
state: Mutex::new(CliProgressState::default()),
}
}
pub fn finish(&self) {
self.file_bar.finish_and_clear();
self.stage_bar.finish_and_clear();
let _ = self.multi.clear();
}
fn handle_started(&self, total_files: usize) {
let mut state = self.state.lock().unwrap();
state.total_files = Some(total_files);
self.file_bar.set_style(self.file_style.clone());
self.file_bar.set_length(total_files as u64);
self.file_bar.set_position(0);
self.file_bar.set_message("Indexing files");
self.stage_bar.set_style(self.stage_spinner_style.clone());
self.stage_bar.set_message("Waiting for ingestion...");
}
fn handle_file_processing(&self, path: &Path, current: usize) {
self.file_bar.set_style(self.file_style.clone());
self.file_bar.set_position(current as u64);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
self.file_bar.set_message(file_name.to_string());
let mut state = self.state.lock().unwrap();
if let Some(total_files) = state.total_files
&& current >= total_files
&& !state.file_bar_finished
{
self.file_bar
.finish_with_message(format!("Files indexed: {total_files}"));
state.file_bar_finished = true;
}
}
fn handle_file_completed(&self, symbols: usize) {
self.file_bar.set_message(format!("{symbols} symbols"));
}
fn handle_ingest_progress(
&self,
files_processed: usize,
total_files: usize,
total_symbols: usize,
counts: &NodeIngestCounts,
elapsed: std::time::Duration,
eta: Option<std::time::Duration>,
) {
self.stage_bar.set_style(self.stage_bar_style.clone());
self.stage_bar.set_length(total_files as u64);
self.stage_bar.set_position(files_processed as u64);
let rate = format_rate(files_processed, elapsed);
let eta_display = eta.map_or_else(|| "--:--".to_string(), format_duration_clock);
let elapsed_display = format_duration_clock(elapsed);
let file_hint = self.current_ingest_file();
let file_suffix = file_hint
.as_deref()
.map(|name| format!(" | file: {name}"))
.unwrap_or_default();
let mut message = format!(
"Ingesting symbols: {total_symbols} symbols | elapsed {elapsed_display} | eta {eta_display} | {rate}{file_suffix}"
);
let _ = write!(message, "\n({})", format_ingest_counts(counts));
self.stage_bar.set_message(message);
}
fn handle_ingest_file_started(&self, path: &Path) {
let file_label = ingest_file_label(path);
{
let mut state = self.state.lock().unwrap();
state.last_ingest_file = Some(file_label.clone());
}
self.stage_bar.set_style(self.stage_bar_style.clone());
self.stage_bar
.set_message(format!("Ingesting {file_label}..."));
}
fn handle_ingest_file_completed(&self, path: &Path, symbols: usize, duration: Duration) {
if is_slow_ingest(duration) {
let warning = format!(
"Warning: slow ingest ({duration:.2?}, {symbols} symbols): {}",
path.display()
);
self.stage_bar.println(warning);
}
}
fn current_ingest_file(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state.last_ingest_file.clone()
}
fn handle_stage_started(&self, stage_name: &str) {
self.stage_bar.set_style(self.stage_spinner_style.clone());
self.stage_bar.set_message(format!("{stage_name}..."));
}
fn handle_stage_completed(&self, stage_name: &str, stage_duration: std::time::Duration) {
self.stage_bar.set_style(self.stage_spinner_style.clone());
self.stage_bar
.set_message(format!("{stage_name} completed in {stage_duration:.2?}"));
}
fn handle_graph_phase_started(&self, phase_number: u8, phase_name: &str, total_items: usize) {
if total_items == 0 {
self.stage_bar.set_style(self.stage_spinner_style.clone());
} else {
self.stage_bar.set_style(self.stage_bar_style.clone());
self.stage_bar.set_length(total_items as u64);
}
self.stage_bar.set_position(0);
self.stage_bar
.set_message(format_graph_phase_message(phase_number, phase_name));
}
fn handle_graph_phase_progress(&self, items_processed: usize, total_items: usize) {
self.stage_bar.set_position(items_processed as u64);
if self.stage_bar.length() != Some(total_items as u64) {
self.stage_bar.set_length(total_items as u64);
}
}
fn handle_graph_phase_completed(
&self,
phase_number: u8,
phase_name: &str,
phase_duration: std::time::Duration,
) {
self.stage_bar.set_message(format!(
"{} completed in {phase_duration:.2?}",
format_graph_phase_message(phase_number, phase_name)
));
}
fn handle_saving_started(&self, component_name: &str) {
self.stage_bar.set_style(self.stage_spinner_style.clone());
self.stage_bar
.set_message(format!("Saving {component_name}..."));
}
fn handle_saving_completed(&self, component_name: &str, save_duration: std::time::Duration) {
self.stage_bar
.set_message(format!("Saved {component_name} in {save_duration:.2?}"));
}
fn handle_completed(&self, total_symbols: usize, duration: std::time::Duration) {
self.stage_bar
.set_message(format!("Indexed {total_symbols} symbols in {duration:.2?}"));
}
}
impl ProgressReporter for CliProgressReporter {
fn report(&self, event: IndexProgress) {
match event {
IndexProgress::Started { total_files } => {
self.handle_started(total_files);
}
IndexProgress::FileProcessing {
path,
current,
total: _,
} => {
self.handle_file_processing(&path, current);
}
IndexProgress::FileCompleted { symbols, .. } => {
self.handle_file_completed(symbols);
}
IndexProgress::IngestProgress {
files_processed,
total_files,
total_symbols,
counts,
elapsed,
eta,
} => {
self.handle_ingest_progress(
files_processed,
total_files,
total_symbols,
&counts,
elapsed,
eta,
);
}
IndexProgress::IngestFileStarted { path, .. } => {
self.handle_ingest_file_started(&path);
}
IndexProgress::IngestFileCompleted {
path,
symbols,
duration,
} => {
self.handle_ingest_file_completed(&path, symbols, duration);
}
IndexProgress::StageStarted { stage_name } => {
self.handle_stage_started(stage_name);
}
IndexProgress::StageCompleted {
stage_name,
stage_duration,
} => {
self.handle_stage_completed(stage_name, stage_duration);
}
IndexProgress::GraphPhaseStarted {
phase_number,
phase_name,
total_items,
} => {
self.handle_graph_phase_started(phase_number, phase_name, total_items);
}
IndexProgress::GraphPhaseProgress {
items_processed,
total_items,
..
} => {
self.handle_graph_phase_progress(items_processed, total_items);
}
IndexProgress::GraphPhaseCompleted {
phase_number,
phase_name,
phase_duration,
} => {
self.handle_graph_phase_completed(phase_number, phase_name, phase_duration);
}
IndexProgress::SavingStarted { component_name } => {
self.handle_saving_started(component_name);
}
IndexProgress::SavingCompleted {
component_name,
save_duration,
} => {
self.handle_saving_completed(component_name, save_duration);
}
IndexProgress::Completed {
total_symbols,
duration,
} => {
self.handle_completed(total_symbols, duration);
}
_ => {}
}
}
}
fn format_ingest_counts(counts: &NodeIngestCounts) -> String {
let mut parts = Vec::new();
parts.push(format!("fn {}", format_count(counts.functions)));
parts.push(format!("mth {}", format_count(counts.methods)));
parts.push(format!("cls {}", format_count(counts.classes)));
if counts.structs > 0 {
parts.push(format!("struct {}", format_count(counts.structs)));
}
if counts.enums > 0 {
parts.push(format!("enum {}", format_count(counts.enums)));
}
if counts.interfaces > 0 {
parts.push(format!("iface {}", format_count(counts.interfaces)));
}
if counts.other > 0 {
parts.push(format!("other {}", format_count(counts.other)));
}
parts.join(", ")
}
fn format_graph_phase_message(phase_number: u8, phase_name: &str) -> String {
if phase_number == 1
&& phase_name == "Chunked structural indexing (parse -> range-plan -> semantic commit)"
{
return format!("Phase 1-3/{TOTAL_GRAPH_PHASES}: {phase_name}");
}
format!("Phase {phase_number}/{TOTAL_GRAPH_PHASES}: {phase_name}")
}
fn ingest_file_label(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.map_or_else(|| path.display().to_string(), ToString::to_string)
}
fn is_slow_ingest(duration: Duration) -> bool {
duration >= Duration::from_secs(SLOW_INGEST_WARNING_SECS)
}
fn format_count(value: usize) -> String {
if value < 1_000 {
return value.to_string();
}
let thousands = value / 1_000;
let remainder = value % 1_000;
if thousands < 10 {
let tenths = remainder / 100;
if tenths == 0 {
format!("{thousands}k")
} else {
format!("{thousands}.{tenths}k")
}
} else {
format!("{thousands}k")
}
}
fn format_rate(files_processed: usize, elapsed: std::time::Duration) -> String {
let elapsed_ms = elapsed.as_millis();
if elapsed_ms == 0 {
return "0 files/sec".to_string();
}
let files_processed = u128::from(files_processed as u64);
let rate = (files_processed * 1_000) / elapsed_ms;
format!("{rate} files/sec")
}
fn format_duration_clock(duration: std::time::Duration) -> String {
let secs = duration.as_secs();
let minutes = secs / 60;
let seconds = secs % 60;
if minutes < 60 {
return format!("{minutes:02}:{seconds:02}");
}
let hours = minutes / 60;
let rem_minutes = minutes % 60;
format!("{hours}h{rem_minutes:02}m")
}
pub struct CliStepProgressReporter {
state: Mutex<StepState>,
}
#[derive(Default)]
struct StepState {
total_files: Option<usize>,
}
impl CliStepProgressReporter {
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(StepState::default()),
}
}
}
impl Default for CliStepProgressReporter {
fn default() -> Self {
Self::new()
}
}
impl ProgressReporter for CliStepProgressReporter {
fn report(&self, event: IndexProgress) {
match event {
IndexProgress::Started { total_files } => {
let mut state = self.state.lock().unwrap();
state.total_files = Some(total_files);
println!("Indexing {total_files} files...");
}
IndexProgress::GraphPhaseStarted {
phase_number,
phase_name,
total_items,
} => {
println!(
"{} ({total_items} items)...",
format_graph_phase_message(phase_number, phase_name)
);
}
IndexProgress::GraphPhaseCompleted {
phase_number,
phase_name,
phase_duration,
} => {
println!(
"{} completed in {phase_duration:.2?}",
format_graph_phase_message(phase_number, phase_name)
);
}
IndexProgress::IngestProgress {
files_processed,
total_files: _,
total_symbols,
counts,
elapsed,
eta,
} => {
let rate = format_rate(files_processed, elapsed);
let eta_display = eta.map_or_else(|| "--:--".to_string(), format_duration_clock);
let elapsed_display = format_duration_clock(elapsed);
println!(
"Ingesting symbols: {total_symbols} symbols | elapsed {elapsed_display} | eta {eta_display} | {rate}"
);
println!("({})", format_ingest_counts(&counts));
}
IndexProgress::IngestFileCompleted {
path,
symbols,
duration,
} => {
if is_slow_ingest(duration) {
println!(
"Warning: slow ingest ({duration:.2?}, {symbols} symbols): {}",
path.display()
);
}
}
IndexProgress::StageStarted { stage_name } => {
println!("Stage: {stage_name}...");
}
IndexProgress::StageCompleted {
stage_name,
stage_duration,
} => {
println!("Stage: {stage_name} completed in {stage_duration:.2?}");
}
IndexProgress::SavingStarted { component_name } => {
println!("Saving {component_name}...");
}
IndexProgress::SavingCompleted {
component_name,
save_duration,
} => {
println!("Saved {component_name} in {save_duration:.2?}");
}
IndexProgress::Completed {
total_symbols,
duration,
} => {
let total_files = self
.state
.lock()
.unwrap()
.total_files
.map_or_else(String::new, |count| format!(" across {count} files"));
println!("Indexed {total_symbols} symbols{total_files} in {duration:.2?}");
}
_ => {}
}
}
}
pub struct StepRunner {
enabled: bool,
step_index: usize,
}
impl StepRunner {
#[must_use]
pub fn new(enabled: bool) -> Self {
Self {
enabled,
step_index: 0,
}
}
pub fn step<T, E, F>(&mut self, name: &str, action: F) -> Result<T, E>
where
E: std::fmt::Display,
F: FnOnce() -> Result<T, E>,
{
self.step_index += 1;
let step_number = self.step_index;
if self.enabled {
println!("Step {step_number}: {name}...");
}
let start = Instant::now();
let result = action();
if self.enabled {
match &result {
Ok(_) => println!(
"Step {step_number}: {name} completed in {:.2?}",
start.elapsed()
),
Err(err) => println!(
"Step {step_number}: {name} failed after {:.2?}: {err}",
start.elapsed()
),
}
}
result
}
}
impl Default for CliProgressReporter {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PlainOutputMode {
Plain,
Json,
}
impl PlainOutputMode {
fn from_env() -> Self {
std::env::var("SQRY_OUTPUT_FORMAT")
.ok()
.filter(|v| v.eq_ignore_ascii_case("json"))
.map_or(Self::Plain, |_| Self::Json)
}
}
pub struct PlainProgressReporter {
mode: PlainOutputMode,
state: Mutex<PlainProgressState>,
}
#[derive(Default)]
struct PlainProgressState {
last_files_emit: Option<Instant>,
}
const PLAIN_FILES_RATE_LIMIT: Duration = Duration::from_millis(250);
impl PlainProgressReporter {
#[must_use]
pub fn new() -> Self {
Self {
mode: PlainOutputMode::from_env(),
state: Mutex::new(PlainProgressState::default()),
}
}
#[must_use]
pub fn for_search(verbose: bool) -> SharedReporter {
if verbose {
Arc::new(Self::new())
} else {
no_op_reporter()
}
}
fn emit_stage_started(&self, stage_name: &'static str) {
write_stage_started(&mut io::stderr().lock(), self.mode, stage_name);
}
fn emit_stage_completed(&self, stage_name: &'static str, duration: Duration) {
write_stage_completed(&mut io::stderr().lock(), self.mode, stage_name, duration);
}
fn emit_summary(&self, total_symbols: usize, duration: Duration) {
write_summary(&mut io::stderr().lock(), self.mode, total_symbols, duration);
}
fn emit_files(&self, current: usize, total: usize) {
write_files(&mut io::stderr().lock(), self.mode, current, total);
}
}
fn write_stage_started<W: IoWrite>(w: &mut W, mode: PlainOutputMode, stage_name: &'static str) {
match mode {
PlainOutputMode::Plain => {
let _ = writeln!(w, "[sqry] {stage_name} ...");
}
PlainOutputMode::Json => {
write_json(
w,
&[
("event", JsonValue::Str("stage_started")),
("stage", JsonValue::Str(stage_name)),
("ts", JsonValue::Num(unix_millis())),
],
);
}
}
}
fn write_stage_completed<W: IoWrite>(
w: &mut W,
mode: PlainOutputMode,
stage_name: &'static str,
duration: Duration,
) {
match mode {
PlainOutputMode::Plain => {
let _ = writeln!(
w,
"[sqry] {stage_name} complete in {}",
format_brief_duration(duration)
);
}
PlainOutputMode::Json => {
let ms = u128_to_u64_saturating(duration.as_millis());
write_json(
w,
&[
("event", JsonValue::Str("stage_completed")),
("stage", JsonValue::Str(stage_name)),
("duration_ms", JsonValue::Num(ms)),
("ts", JsonValue::Num(unix_millis())),
],
);
}
}
}
fn write_summary<W: IoWrite>(
w: &mut W,
mode: PlainOutputMode,
total_symbols: usize,
duration: Duration,
) {
match mode {
PlainOutputMode::Plain => {
let _ = writeln!(
w,
"[sqry] indexing complete: {total_symbols} symbols in {}",
format_brief_duration(duration)
);
}
PlainOutputMode::Json => {
let ms = u128_to_u64_saturating(duration.as_millis());
write_json(
w,
&[
("event", JsonValue::Str("completed")),
("total_symbols", JsonValue::Num(total_symbols as u64)),
("duration_ms", JsonValue::Num(ms)),
("ts", JsonValue::Num(unix_millis())),
],
);
}
}
}
fn write_files<W: IoWrite>(w: &mut W, mode: PlainOutputMode, current: usize, total: usize) {
match mode {
PlainOutputMode::Plain => {
let _ = writeln!(w, "[sqry] files processed {current}/{total}");
}
PlainOutputMode::Json => {
write_json(
w,
&[
("event", JsonValue::Str("files_progress")),
("current", JsonValue::Num(current as u64)),
("total", JsonValue::Num(total as u64)),
("ts", JsonValue::Num(unix_millis())),
],
);
}
}
}
fn write_json<W: IoWrite>(w: &mut W, fields: &[(&str, JsonValue)]) {
let _ = w.write_all(b"{");
for (i, (key, value)) in fields.iter().enumerate() {
if i > 0 {
let _ = w.write_all(b",");
}
debug_assert!(
!key.contains('"') && !key.contains('\\'),
"json key must not need escaping: {key}"
);
let _ = write!(w, "\"{key}\":");
match value {
JsonValue::Str(s) => {
debug_assert!(
!s.contains('"') && !s.contains('\\'),
"json string value must not need escaping: {s}"
);
let _ = write!(w, "\"{s}\"");
}
JsonValue::Num(n) => {
let _ = write!(w, "{n}");
}
}
}
let _ = w.write_all(b"}\n");
}
impl Default for PlainProgressReporter {
fn default() -> Self {
Self::new()
}
}
enum JsonValue {
Str(&'static str),
Num(u64),
}
impl ProgressReporter for PlainProgressReporter {
fn report(&self, event: IndexProgress) {
match event {
IndexProgress::StageStarted { stage_name } => {
self.emit_stage_started(stage_name);
}
IndexProgress::StageCompleted {
stage_name,
stage_duration,
} => {
self.emit_stage_completed(stage_name, stage_duration);
}
IndexProgress::FileProcessing { current, total, .. } => {
let should_emit = {
let mut state = match self.state.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let now = Instant::now();
let allow = state
.last_files_emit
.is_none_or(|t| now.duration_since(t) >= PLAIN_FILES_RATE_LIMIT);
if allow {
state.last_files_emit = Some(now);
}
allow
};
if should_emit {
self.emit_files(current, total);
}
}
IndexProgress::Completed {
total_symbols,
duration,
} => {
self.emit_summary(total_symbols, duration);
}
_ => {}
}
}
}
fn format_brief_duration(d: Duration) -> String {
let secs = d.as_secs_f64();
if secs >= 1.0 {
format!("{secs:.2}s")
} else if d.as_millis() >= 1 {
format!("{}ms", d.as_millis())
} else {
format!("{}us", d.as_micros())
}
}
fn unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u128_to_u64_saturating(d.as_millis()))
.unwrap_or(0)
}
fn u128_to_u64_saturating(v: u128) -> u64 {
if v > u64::MAX as u128 {
u64::MAX
} else {
v as u64
}
}
#[cfg(test)]
mod tests {
use super::{format_duration_clock, format_graph_phase_message, format_rate};
use std::time::Duration;
#[test]
fn test_format_rate_zero_elapsed() {
assert_eq!(format_rate(0, Duration::from_secs(0)), "0 files/sec");
}
#[test]
fn test_format_rate_per_second() {
assert_eq!(format_rate(1000, Duration::from_secs(1)), "1000 files/sec");
}
#[test]
fn test_format_rate_fractional_seconds() {
assert_eq!(format_rate(1500, Duration::from_secs(2)), "750 files/sec");
}
#[test]
fn test_format_duration_clock_under_hour() {
assert_eq!(format_duration_clock(Duration::from_secs(65)), "01:05");
}
#[test]
fn test_format_duration_clock_hour_boundary() {
assert_eq!(format_duration_clock(Duration::from_secs(3600)), "1h00m");
}
#[test]
fn test_format_duration_clock_hours_minutes() {
assert_eq!(format_duration_clock(Duration::from_secs(3720)), "1h02m");
}
#[test]
fn test_format_graph_phase_message() {
assert_eq!(
format_graph_phase_message(
1,
"Chunked structural indexing (parse -> range-plan -> semantic commit)"
),
"Phase 1-3/5: Chunked structural indexing (parse -> range-plan -> semantic commit)"
);
}
}
#[cfg(test)]
mod plain_reporter_tests {
use super::{
PLAIN_FILES_RATE_LIMIT, PlainOutputMode, PlainProgressReporter, format_brief_duration,
write_files, write_stage_completed, write_stage_started, write_summary,
};
use sqry_core::progress::{IndexProgress, ProgressReporter};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
fn captured<F: FnOnce(&mut Vec<u8>)>(f: F) -> String {
let mut buf = Vec::new();
f(&mut buf);
String::from_utf8(buf).expect("plain-reporter output must be valid utf8")
}
#[test]
fn format_brief_duration_sub_millis_uses_us() {
let s = format_brief_duration(Duration::from_micros(42));
assert_eq!(s, "42us");
}
#[test]
fn format_brief_duration_sub_second_uses_ms() {
let s = format_brief_duration(Duration::from_millis(150));
assert_eq!(s, "150ms");
}
#[test]
fn format_brief_duration_super_second_uses_two_decimals() {
let s = format_brief_duration(Duration::from_millis(1240));
assert_eq!(s, "1.24s");
}
fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var(key).ok();
unsafe {
if let Some(v) = value {
std::env::set_var(key, v);
} else {
std::env::remove_var(key);
}
}
f();
unsafe {
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
#[test]
fn output_mode_default_is_plain_when_env_unset() {
with_env("SQRY_OUTPUT_FORMAT", None, || {
assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Plain);
});
}
#[test]
fn output_mode_json_when_env_eq_json() {
with_env("SQRY_OUTPUT_FORMAT", Some("json"), || {
assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Json);
});
}
#[test]
fn output_mode_json_is_case_insensitive() {
with_env("SQRY_OUTPUT_FORMAT", Some("JSON"), || {
assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Json);
});
with_env("SQRY_OUTPUT_FORMAT", Some("Json"), || {
assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Json);
});
}
#[test]
fn output_mode_plain_when_env_unrecognised() {
with_env("SQRY_OUTPUT_FORMAT", Some("yaml"), || {
assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Plain);
});
with_env("SQRY_OUTPUT_FORMAT", Some(""), || {
assert_eq!(PlainOutputMode::from_env(), PlainOutputMode::Plain);
});
}
#[test]
fn for_search_false_returns_silent_reporter() {
let reporter = PlainProgressReporter::for_search(false);
reporter.report(IndexProgress::StageStarted {
stage_name: "test stage",
});
}
#[test]
fn for_search_true_returns_plain_reporter() {
let reporter = PlainProgressReporter::for_search(true);
reporter.report(IndexProgress::StageStarted {
stage_name: "test stage",
});
reporter.report(IndexProgress::StageCompleted {
stage_name: "test stage",
stage_duration: Duration::from_millis(5),
});
}
#[test]
fn plain_stage_started_format() {
let out = captured(|w| write_stage_started(w, PlainOutputMode::Plain, "load snapshot"));
assert_eq!(out, "[sqry] load snapshot ...\n");
}
#[test]
fn plain_stage_completed_format() {
let out = captured(|w| {
write_stage_completed(
w,
PlainOutputMode::Plain,
"load snapshot",
Duration::from_millis(150),
);
});
assert_eq!(out, "[sqry] load snapshot complete in 150ms\n");
}
#[test]
fn plain_summary_format() {
let out = captured(|w| {
write_summary(w, PlainOutputMode::Plain, 12345, Duration::from_millis(890));
});
assert_eq!(out, "[sqry] indexing complete: 12345 symbols in 890ms\n");
}
#[test]
fn plain_files_format() {
let out = captured(|w| write_files(w, PlainOutputMode::Plain, 5, 100));
assert_eq!(out, "[sqry] files processed 5/100\n");
}
#[test]
fn plain_output_contains_no_ansi_escape_sequences() {
let buf = captured(|w| {
write_stage_started(w, PlainOutputMode::Plain, "load snapshot");
write_stage_completed(
w,
PlainOutputMode::Plain,
"load snapshot",
Duration::from_millis(150),
);
write_files(w, PlainOutputMode::Plain, 1, 2);
write_summary(w, PlainOutputMode::Plain, 10, Duration::from_secs(1));
});
assert!(
!buf.contains('\x1b'),
"plain mode emitted an ANSI escape sequence: {buf:?}"
);
}
fn parse_jsonl_line(line: &str) -> serde_json::Value {
serde_json::from_str(line).expect("each json-line must parse as a JSON object")
}
#[test]
fn json_stage_started_has_required_fields() {
let out = captured(|w| write_stage_started(w, PlainOutputMode::Json, "exact name lookup"));
assert!(out.ends_with('\n'), "json line must be newline-terminated");
let v = parse_jsonl_line(out.trim_end());
assert_eq!(v["event"], "stage_started");
assert_eq!(v["stage"], "exact name lookup");
assert!(v["ts"].is_number(), "ts must be a number");
}
#[test]
fn json_stage_completed_has_duration_ms() {
let out = captured(|w| {
write_stage_completed(
w,
PlainOutputMode::Json,
"load snapshot",
Duration::from_millis(42),
);
});
let v = parse_jsonl_line(out.trim_end());
assert_eq!(v["event"], "stage_completed");
assert_eq!(v["stage"], "load snapshot");
assert_eq!(v["duration_ms"], 42);
}
#[test]
fn json_files_progress_has_current_and_total() {
let out = captured(|w| write_files(w, PlainOutputMode::Json, 7, 99));
let v = parse_jsonl_line(out.trim_end());
assert_eq!(v["event"], "files_progress");
assert_eq!(v["current"], 7);
assert_eq!(v["total"], 99);
}
#[test]
fn json_summary_has_total_symbols() {
let out = captured(|w| {
write_summary(w, PlainOutputMode::Json, 3, Duration::from_millis(8));
});
let v = parse_jsonl_line(out.trim_end());
assert_eq!(v["event"], "completed");
assert_eq!(v["total_symbols"], 3);
assert_eq!(v["duration_ms"], 8);
}
#[test]
fn json_mode_emits_one_object_per_line() {
let buf = captured(|w| {
write_stage_started(w, PlainOutputMode::Json, "load snapshot");
write_stage_completed(
w,
PlainOutputMode::Json,
"load snapshot",
Duration::from_millis(5),
);
write_stage_started(w, PlainOutputMode::Json, "exact name lookup");
});
let lines: Vec<&str> = buf.lines().collect();
assert_eq!(lines.len(), 3, "expected exactly three JSON lines");
for line in lines {
let _ = parse_jsonl_line(line);
}
}
#[test]
fn file_processing_rate_limit_is_250ms() {
assert_eq!(PLAIN_FILES_RATE_LIMIT, Duration::from_millis(250));
}
#[test]
fn report_does_not_panic_on_event_flood() {
let reporter = Arc::new(PlainProgressReporter::new());
for i in 0..50_000 {
reporter.report(IndexProgress::StageStarted {
stage_name: "stress",
});
reporter.report(IndexProgress::FileProcessing {
path: std::path::PathBuf::from("/tmp/stress"),
current: i,
total: 50_000,
});
}
}
#[test]
fn report_is_thread_safe_under_concurrent_emitters() {
let reporter = Arc::new(PlainProgressReporter::new());
let handles: Vec<_> = (0..8)
.map(|t| {
let r = Arc::clone(&reporter);
thread::spawn(move || {
for i in 0..1_000 {
r.report(IndexProgress::StageStarted {
stage_name: "concurrent",
});
r.report(IndexProgress::FileProcessing {
path: std::path::PathBuf::from(format!("/tmp/{t}/{i}")),
current: i,
total: 1_000,
});
}
})
})
.collect();
for h in handles {
h.join().expect("worker thread must not panic");
}
}
}