use super::format::{fmt_elapsed, fmt_secs, format_with_commas};
use colored::Colorize;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::time::Duration;
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReindexPhase {
Connecting,
Walking,
Chunking,
Embedding,
ParseEmbed,
KnowledgeGraph,
Bm25,
Upsert,
Done,
}
impl ReindexPhase {
pub(crate) fn label(self) -> &'static str {
match self {
ReindexPhase::Connecting => "Connecting to daemon\u{2026}",
ReindexPhase::Walking => "Walking files\u{2026}",
ReindexPhase::Chunking => "Chunking\u{2026}",
ReindexPhase::Embedding => "Embedding chunks\u{2026}",
ReindexPhase::ParseEmbed => "Embedding chunks\u{2026}",
ReindexPhase::Bm25 => "Building BM25 index\u{2026}",
ReindexPhase::KnowledgeGraph => "Building knowledge graph\u{2026}",
ReindexPhase::Upsert => "Upserting vectors\u{2026}",
ReindexPhase::Done => "Done",
}
}
}
fn phase_to_bar_slot(phase: ReindexPhase) -> Option<usize> {
match phase {
ReindexPhase::Walking => Some(0),
ReindexPhase::Chunking => Some(1),
ReindexPhase::Embedding | ReindexPhase::ParseEmbed => Some(2),
ReindexPhase::KnowledgeGraph => Some(3),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BarState {
Pending,
Active,
Done,
}
const STAGE_LABELS: [&str; 4] = ["Crawl", "Chunk", "Embed", "KG"];
fn bar_style(slot: usize, state: BarState, elapsed_ms: Option<u64>) -> ProgressStyle {
let label = STAGE_LABELS[slot];
match state {
BarState::Pending => {
let tpl = format!(" {{spinner:.white}} {label:<5} [{{bar:40.white/white}}] pending");
ProgressStyle::with_template(&tpl)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("\u{2588}\u{2591} ")
}
BarState::Active => {
let tpl = format!(
" {{spinner:.cyan}} {label:<5} [{{bar:40.cyan/blue}}] {{pos}}/{{len}} {{msg}}"
);
ProgressStyle::with_template(&tpl)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("\u{2588}\u{2591} ")
}
BarState::Done => {
let t = elapsed_ms.unwrap_or(0);
let elapsed_str = fmt_elapsed(t);
let tpl = format!(
" \u{2713} {label:<5} [{{bar:40.green/green}}] {{pos}}/{{len}} \u{2014} done in {elapsed_str}"
);
ProgressStyle::with_template(&tpl)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("\u{2588}\u{2591} ")
}
}
}
pub(crate) struct ReindexUi {
#[allow(dead_code)]
multi: MultiProgress,
header: ProgressBar,
stage_bars: [ProgressBar; 4],
stage_elapsed_ms: [u64; 4],
stats: ProgressBar,
pub(crate) phase: ReindexPhase,
bar_states: [BarState; 4],
}
impl ReindexUi {
pub(crate) fn new(index_id: &str, interactive: bool) -> Self {
let multi = if interactive {
MultiProgress::with_draw_target(ProgressDrawTarget::stderr())
} else {
MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
};
let header = multi.add(ProgressBar::new(1));
if let Ok(s) = ProgressStyle::with_template("{spinner:.cyan} {msg}") {
header.set_style(s);
}
header.set_message(format!(
"{} \u{2014} {}",
ReindexPhase::Connecting.label(),
index_id.bold()
));
header.enable_steady_tick(Duration::from_millis(120));
let mut stage_bars_arr: [Option<ProgressBar>; 4] = [None, None, None, None];
for (slot, item) in stage_bars_arr.iter_mut().enumerate() {
let pb = multi.add(ProgressBar::new(1));
pb.set_style(bar_style(slot, BarState::Pending, None));
pb.set_position(0);
*item = Some(pb);
}
let stage_bars = [
stage_bars_arr[0].take().expect("slot 0"),
stage_bars_arr[1].take().expect("slot 1"),
stage_bars_arr[2].take().expect("slot 2"),
stage_bars_arr[3].take().expect("slot 3"),
];
let stats = multi.add(ProgressBar::new(1));
if let Ok(s) = ProgressStyle::with_template(" {msg}") {
stats.set_style(s);
}
stats.set_message("Waiting for daemon\u{2026}".to_string());
Self {
multi,
header,
stage_bars,
stage_elapsed_ms: [0u64; 4],
stats,
phase: ReindexPhase::Connecting,
bar_states: [BarState::Pending; 4],
}
}
pub(crate) fn set_phase(&mut self, phase: ReindexPhase, index_id: &str) {
self.phase = phase;
self.header
.set_message(format!("{} \u{2014} {}", phase.label(), index_id.bold()));
if let Some(slot) = phase_to_bar_slot(phase) {
if self.bar_states[slot] != BarState::Done {
self.bar_states[slot] = BarState::Active;
self.stage_bars[slot].set_style(bar_style(slot, BarState::Active, None));
self.stage_bars[slot].set_position(0);
}
}
}
pub(crate) fn set_total(&self, total: u64) {
if let Some(slot) = phase_to_bar_slot(self.phase) {
self.stage_bars[slot].set_length(total.max(1));
}
}
pub(crate) fn set_position(&self, pos: u64) {
if let Some(slot) = phase_to_bar_slot(self.phase) {
self.stage_bars[slot].set_position(pos);
}
}
pub(crate) fn mark_stage_done(&mut self, slot: usize, elapsed_ms: u64) {
if slot >= 4 {
return;
}
self.bar_states[slot] = BarState::Done;
self.stage_elapsed_ms[slot] = elapsed_ms;
let len = self.stage_bars[slot].length().unwrap_or(1);
self.stage_bars[slot].set_length(len.max(1));
self.stage_bars[slot].set_position(len);
self.stage_bars[slot].set_style(bar_style(slot, BarState::Done, Some(elapsed_ms)));
}
pub(crate) fn update_stats(
&self,
indexed: u64,
total_chunks: u64,
skipped: u64,
chunks_per_sec: u64,
elapsed_secs: u64,
) {
let total = if let Some(slot) = phase_to_bar_slot(self.phase) {
self.stage_bars[slot].length().unwrap_or(0)
} else {
0
};
let files_per_sec = indexed.checked_div(elapsed_secs).unwrap_or(0);
let eta = if files_per_sec > 0 && total > indexed {
fmt_secs((total - indexed) / files_per_sec)
} else {
"?".to_string()
};
self.stats.set_message(format!(
"Embedding\u{2026} {chunks} chunks \u{2014} {cps} cps \u{2014} Files {indexed}/{total} Skipped {skipped} Elapsed {elapsed} ETA {eta}",
chunks = format_with_commas(total_chunks),
cps = chunks_per_sec,
indexed = format_with_commas(indexed),
total = format_with_commas(total),
skipped = format_with_commas(skipped),
elapsed = fmt_secs(elapsed_secs),
eta = eta,
));
}
pub(crate) fn clear_stats(&self) {
self.stats.set_message(String::new());
}
pub(crate) fn finish(self, final_msg: String) {
for slot in 0..4 {
if self.bar_states[slot] != BarState::Done {
self.stage_bars[slot].finish_and_clear();
}
}
self.stats.finish_and_clear();
self.header.finish_with_message(final_msg);
}
pub(crate) fn abandon(self, final_msg: String) {
for bar in &self.stage_bars {
bar.abandon();
}
self.stats.abandon();
self.header.abandon_with_message(final_msg);
}
pub(crate) fn stats_bar(&self) -> ProgressBar {
self.stats.clone()
}
pub(crate) fn embed_bar(&self) -> ProgressBar {
self.stage_bars[2].clone()
}
}
pub fn print_timing_breakdown(t: &ReindexTimings, total_chunks: u64) {
println!(
" {} {:>7} ({} chunks)",
"Parse/chunk: ".dimmed(),
fmt_elapsed(t.parse_ms),
format_with_commas(total_chunks),
);
if t.vector_count == 0 && total_chunks > 0 {
println!(
" {} {}",
"Embed: ".dimmed(),
"SKIPPED (embedder unavailable \u{2014} BM25-only mode)"
.yellow()
.bold(),
);
} else {
println!(
" {} {:>7} ({} vectors)",
"Embed: ".dimmed(),
fmt_elapsed(t.embed_ms),
format_with_commas(t.vector_count),
);
}
println!(
" {} {:>7} ({} vectors)",
"Upsert vectors:".dimmed(),
fmt_elapsed(t.vector_upsert_ms),
format_with_commas(t.vector_count),
);
println!(
" {} {:>7}",
"BM25 index: ".dimmed(),
fmt_elapsed(t.bm25_ms)
);
println!(
" {} {:>7} ({} symbols, {} edges)",
"Knowledge graph:".dimmed(),
fmt_elapsed(t.kg_ms),
format_with_commas(t.symbol_count),
format_with_commas(t.edge_count),
);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ReindexTimings {
pub parse_ms: u64,
pub embed_ms: u64,
pub bm25_ms: u64,
pub vector_upsert_ms: u64,
pub kg_ms: u64,
pub vector_count: u64,
pub symbol_count: u64,
pub edge_count: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase_labels_are_stable() {
assert_eq!(
ReindexPhase::Connecting.label(),
"Connecting to daemon\u{2026}"
);
assert_eq!(ReindexPhase::Walking.label(), "Walking files\u{2026}");
assert_eq!(ReindexPhase::Chunking.label(), "Chunking\u{2026}");
assert_eq!(ReindexPhase::Embedding.label(), "Embedding chunks\u{2026}");
assert_eq!(ReindexPhase::ParseEmbed.label(), "Embedding chunks\u{2026}");
assert_eq!(ReindexPhase::Bm25.label(), "Building BM25 index\u{2026}");
assert_eq!(
ReindexPhase::KnowledgeGraph.label(),
"Building knowledge graph\u{2026}"
);
assert_eq!(ReindexPhase::Upsert.label(), "Upserting vectors\u{2026}");
assert_eq!(ReindexPhase::Done.label(), "Done");
}
#[test]
fn phase_to_bar_slot_coverage() {
assert_eq!(phase_to_bar_slot(ReindexPhase::Connecting), None);
assert_eq!(phase_to_bar_slot(ReindexPhase::Walking), Some(0));
assert_eq!(phase_to_bar_slot(ReindexPhase::Chunking), Some(1));
assert_eq!(phase_to_bar_slot(ReindexPhase::Embedding), Some(2));
assert_eq!(phase_to_bar_slot(ReindexPhase::ParseEmbed), Some(2));
assert_eq!(phase_to_bar_slot(ReindexPhase::KnowledgeGraph), Some(3));
assert_eq!(phase_to_bar_slot(ReindexPhase::Bm25), None);
assert_eq!(phase_to_bar_slot(ReindexPhase::Upsert), None);
assert_eq!(phase_to_bar_slot(ReindexPhase::Done), None);
}
#[test]
fn ui_builds_hidden_when_not_interactive() {
let mut ui = ReindexUi::new("test-index", false);
assert_eq!(ui.phase, ReindexPhase::Connecting);
ui.set_phase(ReindexPhase::Walking, "test-index");
assert_eq!(ui.phase, ReindexPhase::Walking);
ui.set_total(1_000);
ui.set_position(1_000);
ui.mark_stage_done(0, 1_200);
ui.set_phase(ReindexPhase::Chunking, "test-index");
assert_eq!(ui.phase, ReindexPhase::Chunking);
ui.set_total(1_000);
ui.mark_stage_done(1, 300);
ui.set_phase(ReindexPhase::Embedding, "test-index");
assert_eq!(ui.phase, ReindexPhase::Embedding);
ui.set_total(1_000);
ui.set_position(500);
ui.update_stats(500, 4_096, 3, 128, 10);
ui.mark_stage_done(2, 90_000);
ui.set_phase(ReindexPhase::KnowledgeGraph, "test-index");
assert_eq!(ui.phase, ReindexPhase::KnowledgeGraph);
ui.set_total(1);
ui.set_position(1);
ui.clear_stats();
ui.mark_stage_done(3, 800);
ui.finish("done".to_string());
}
#[test]
fn ui_builds_interactive() {
let ui = ReindexUi::new("test-index", true);
assert_eq!(ui.phase, ReindexPhase::Connecting);
ui.abandon("aborted".to_string());
}
#[test]
fn phase_transitions_activate_correct_bar() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
assert_eq!(ui.phase, ReindexPhase::Walking);
assert_eq!(ui.bar_states[0], BarState::Active);
ui.set_phase(ReindexPhase::Chunking, "idx");
assert_eq!(ui.phase, ReindexPhase::Chunking);
assert_eq!(ui.bar_states[1], BarState::Active);
ui.set_phase(ReindexPhase::Embedding, "idx");
assert_eq!(ui.phase, ReindexPhase::Embedding);
assert_eq!(ui.bar_states[2], BarState::Active);
ui.set_phase(ReindexPhase::KnowledgeGraph, "idx");
assert_eq!(ui.phase, ReindexPhase::KnowledgeGraph);
assert_eq!(ui.bar_states[3], BarState::Active);
ui.finish("done".to_string());
}
#[test]
fn mark_stage_done_freezes_bar() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
ui.set_total(500);
ui.set_position(500);
ui.mark_stage_done(0, 1_200);
assert_eq!(ui.bar_states[0], BarState::Done);
assert_eq!(ui.stage_elapsed_ms[0], 1_200);
ui.set_phase(ReindexPhase::Walking, "idx");
assert_eq!(ui.bar_states[0], BarState::Done);
ui.finish("done".to_string());
}
#[test]
fn set_total_and_position_affect_active_bar() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(200);
ui.set_position(100);
assert_eq!(ui.stage_bars[1].length(), Some(200));
assert_eq!(ui.stage_bars[1].position(), 100);
ui.finish("done".to_string());
}
#[test]
fn update_stats_formats_message() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Embedding, "idx");
ui.update_stats(0, 0, 0, 0, 0);
ui.update_stats(500, 4_096, 3, 128, 10);
ui.finish("done".to_string());
}
#[test]
fn clear_stats_empties_message() {
let ui = ReindexUi::new("idx", false);
ui.clear_stats();
ui.finish("done".to_string());
}
#[test]
fn finish_all_clears_pending_bars() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
ui.set_total(100);
ui.set_position(100);
ui.mark_stage_done(0, 500);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(100);
ui.mark_stage_done(1, 200);
ui.set_phase(ReindexPhase::Embedding, "idx");
ui.set_total(100);
ui.set_position(100);
ui.mark_stage_done(2, 80_000);
assert_eq!(ui.bar_states[3], BarState::Pending);
ui.finish("lexical-only done".to_string());
}
#[test]
fn abandon_does_not_panic() {
let ui = ReindexUi::new("idx", false);
ui.abandon("timed out".to_string());
}
#[test]
fn timing_breakdown_bm25_only_does_not_panic() {
let t = ReindexTimings {
parse_ms: 1_000,
embed_ms: 0,
bm25_ms: 200,
vector_upsert_ms: 0,
kg_ms: 50,
vector_count: 0,
symbol_count: 10,
edge_count: 4,
};
print_timing_breakdown(&t, 1_234);
}
#[test]
fn timing_breakdown_normal_does_not_panic() {
let t = ReindexTimings {
parse_ms: 5_000,
embed_ms: 90_000,
bm25_ms: 1_200,
vector_upsert_ms: 3_400,
kg_ms: 800,
vector_count: 62_926,
symbol_count: 14_823,
edge_count: 41_002,
};
print_timing_breakdown(&t, 62_926);
}
}