use super::daemon_utils::daemon_base_url;
use super::format::{fmt_elapsed, fmt_secs, format_with_commas};
use anyhow::Result;
use colored::Colorize;
use eventsource_stream::Eventsource;
use futures_util::stream::StreamExt;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::io::IsTerminal;
use std::time::Duration;
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 — 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),
);
}
pub async fn index_single_file(
client: &reqwest::Client,
base: &str,
index_id: &str,
file: &std::path::Path,
) -> Result<()> {
let content = tokio::fs::read_to_string(file)
.await
.map_err(|e| anyhow::anyhow!("read {}: {e}", file.display()))?;
let url = format!("{}/indexes/{}/index-file", base, index_id);
let body = serde_json::json!({
"path": file.display().to_string(),
"content": content,
});
let resp = client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
anyhow::bail!("daemon returned {} for {}", resp.status(), url);
}
Ok(())
}
pub async fn add_path(index_id: &str, path: &std::path::Path) -> Result<()> {
let base = daemon_base_url();
let client = trusty_common::server::daemon_http_client()?;
if path.is_dir() {
let walk = crate::service::walker::walk_source_files(path);
println!(
"{} [{}] indexing {} files under {}",
"→".cyan(),
index_id,
walk.files.len(),
path.display()
);
let mut ok = 0usize;
let mut err = 0usize;
for f in &walk.files {
match index_single_file(&client, &base, index_id, f).await {
Ok(()) => ok += 1,
Err(e) => {
eprintln!(" {} {}: {e}", "⚠".yellow(), f.display());
err += 1;
}
}
}
println!("{} indexed {} files ({} errors)", "✓".green(), ok, err);
Ok(())
} else {
index_single_file(&client, &base, index_id, path).await?;
println!("{} [{}] {}", "→".cyan(), index_id, path.display());
Ok(())
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReindexPhase {
Connecting,
ParseEmbed,
Bm25,
KnowledgeGraph,
Upsert,
Done,
}
impl ReindexPhase {
fn label(self) -> &'static str {
match self {
ReindexPhase::Connecting => "Connecting to daemon…",
ReindexPhase::ParseEmbed => "Parsing & embedding files",
ReindexPhase::Bm25 => "Building BM25 index…",
ReindexPhase::KnowledgeGraph => "Building knowledge graph…",
ReindexPhase::Upsert => "Upserting vectors…",
ReindexPhase::Done => "Done",
}
}
}
struct ReindexUi {
#[allow(dead_code)]
multi: MultiProgress,
header: ProgressBar,
files: ProgressBar,
stats: ProgressBar,
phase: ReindexPhase,
}
fn files_bar_chunk_msg(chunks: u64) -> String {
format!("{} chunks", format_with_commas(chunks))
}
impl ReindexUi {
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!(
"{} — {}",
ReindexPhase::Connecting.label(),
index_id.bold()
));
header.enable_steady_tick(Duration::from_millis(120));
let files = multi.add(ProgressBar::new(1));
if let Ok(s) = ProgressStyle::with_template(
" [{bar:40.cyan/blue}] {pos}/{len} files • {msg} ({percent}%) — ETA {eta}",
) {
files.set_style(s.progress_chars("█░ "));
}
files.set_message(files_bar_chunk_msg(0));
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…".to_string());
Self {
multi,
header,
files,
stats,
phase: ReindexPhase::Connecting,
}
}
fn set_phase(&mut self, phase: ReindexPhase, index_id: &str) {
self.phase = phase;
self.header
.set_message(format!("{} — {}", phase.label(), index_id.bold()));
if phase == ReindexPhase::ParseEmbed {
self.files.set_position(0);
}
}
fn set_total(&self, total: u64) {
self.files.set_length(total.max(1));
}
fn set_position(&self, indexed: u64) {
self.files.set_position(indexed);
}
fn update_stats(
&self,
indexed: u64,
total_chunks: u64,
skipped: u64,
chunks_per_sec: u64,
elapsed_secs: u64,
) {
let total = self.files.length().unwrap_or(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.files.set_message(files_bar_chunk_msg(total_chunks));
self.stats.set_message(format!(
"Embedding… {chunks} chunks — {cps} cps — 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,
));
}
fn finish(self, final_msg: String) {
self.files.finish_and_clear();
self.stats.finish_and_clear();
self.header.finish_with_message(final_msg);
}
fn abandon(self, final_msg: String) {
self.files.abandon();
self.stats.abandon();
self.header.abandon_with_message(final_msg);
}
}
#[derive(Debug, Clone, Copy)]
pub struct ReindexOptions {
pub verify_after: bool,
pub prior_chunk_count: Option<u64>,
pub force: bool,
pub timeout_secs: u64,
}
impl Default for ReindexOptions {
fn default() -> Self {
Self {
verify_after: false,
prior_chunk_count: None,
force: false,
timeout_secs: 600,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ReindexOutcome {
pub indexed: u64,
pub total_chunks: u64,
pub skipped: u64,
pub errors: u64,
pub elapsed_ms: u64,
pub completed: bool,
pub timings: Option<ReindexTimings>,
}
#[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,
}
pub async fn run_reindex(
index_id: &str,
root_path: &std::path::Path,
timeout_secs: u64,
) -> Result<()> {
run_reindex_with(
index_id,
root_path,
ReindexOptions {
timeout_secs,
..ReindexOptions::default()
},
)
.await
.map(|_| ())
}
pub async fn run_reindex_force(
index_id: &str,
root_path: &std::path::Path,
timeout_secs: u64,
) -> Result<()> {
let prior = fetch_chunk_count(index_id).await;
let opts = ReindexOptions {
verify_after: true,
prior_chunk_count: prior,
force: true,
timeout_secs,
};
run_reindex_with(index_id, root_path, opts)
.await
.map(|_| ())
}
pub async fn run_reindex_with(
index_id: &str,
root_path: &std::path::Path,
opts: ReindexOptions,
) -> Result<ReindexOutcome> {
let base = daemon_base_url();
let client = trusty_common::server::daemon_http_client()?;
let kickoff_url = format!("{}/indexes/{}/reindex", base, index_id);
let kickoff_body = serde_json::json!({
"root_path": root_path,
"force": opts.force,
});
let kickoff = client
.post(&kickoff_url)
.json(&kickoff_body)
.send()
.await
.map_err(|e| anyhow::anyhow!("could not reach daemon at {base}: {e}"))?;
if kickoff.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"index '{}' is not registered on the daemon — run `trusty-search index` first",
index_id
);
}
if !kickoff.status().is_success() {
anyhow::bail!("daemon returned {} for reindex kickoff", kickoff.status());
}
let kickoff_body: serde_json::Value = kickoff
.json()
.await
.unwrap_or_else(|_| serde_json::json!({}));
let stream_path = kickoff_body
.get("stream_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("/indexes/{}/reindex/stream", index_id));
let stream_url = format!("{}{}", base, stream_path);
let sse_client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::MAX)
.build()
.map_err(|e| anyhow::anyhow!("could not build SSE client: {e}"))?;
let resp = sse_client
.get(&stream_url)
.send()
.await
.map_err(|e| anyhow::anyhow!("could not connect to SSE stream {stream_url}: {e}"))?;
if !resp.status().is_success() {
anyhow::bail!(
"reindex stream returned {} — daemon may be an older version that doesn't support /reindex/stream",
resp.status()
);
}
let interactive = std::io::stdout().is_terminal();
let mut ui = ReindexUi::new(index_id, interactive);
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc as StdArc;
let started = std::time::Instant::now();
let indexed_now = StdArc::new(AtomicU64::new(0));
let chunks_now = StdArc::new(AtomicU64::new(0));
let skipped_now = StdArc::new(AtomicU64::new(0));
let cps_now = StdArc::new(AtomicU64::new(0));
let tick_done = StdArc::new(AtomicBool::new(false));
let ticker = {
let indexed_now = indexed_now.clone();
let chunks_now = chunks_now.clone();
let skipped_now = skipped_now.clone();
let cps_now = cps_now.clone();
let tick_done = tick_done.clone();
let stats_bar = ui.stats.clone();
let files_bar = ui.files.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.tick().await; loop {
interval.tick().await;
if tick_done.load(Ordering::Acquire) {
break;
}
let elapsed = started.elapsed().as_secs();
let indexed = indexed_now.load(Ordering::Acquire);
let chunks = chunks_now.load(Ordering::Acquire);
let skipped = skipped_now.load(Ordering::Acquire);
let cps = cps_now.load(Ordering::Acquire);
let fps = indexed.checked_div(elapsed).unwrap_or(0);
let total = files_bar.length().unwrap_or(0);
let eta = if fps > 0 && total > indexed {
fmt_secs((total - indexed) / fps)
} else {
"?".to_string()
};
files_bar.set_message(files_bar_chunk_msg(chunks));
stats_bar.set_message(format!(
"Embedding… {chunks} chunks — {cps} cps — Files {indexed}/{total} Skipped {skipped} Elapsed {elapsed}s ETA {eta}",
chunks = format_with_commas(chunks),
cps = cps,
indexed = format_with_commas(indexed),
total = format_with_commas(total),
skipped = format_with_commas(skipped),
elapsed = elapsed,
eta = eta,
));
}
})
};
let mut outcome = ReindexOutcome::default();
let mut done = false;
let mut timed_out = false;
let deadline: Option<tokio::time::Instant> = if opts.timeout_secs > 0 {
Some(tokio::time::Instant::now() + Duration::from_secs(opts.timeout_secs))
} else {
None
};
let byte_stream = resp.bytes_stream();
let stream = byte_stream.eventsource();
tokio::pin!(stream);
while !done {
let maybe_event = if let Some(dl) = deadline {
tokio::select! {
biased;
ev = stream.next() => ev,
_ = tokio::time::sleep_until(dl) => {
timed_out = true;
break;
}
}
} else {
stream.next().await
};
let event = match maybe_event {
Some(Ok(e)) => e,
Some(Err(e)) => {
ui.stats
.println(format!("{} stream read error: {e}", "⚠".yellow()));
break;
}
None => break,
};
let evt: serde_json::Value = match serde_json::from_str(event.data.trim()) {
Ok(v) => v,
Err(_) => continue,
};
match evt.get("event").and_then(|v| v.as_str()) {
Some("start") => {
let total = evt.get("total_files").and_then(|v| v.as_u64()).unwrap_or(0);
ui.set_total(total);
ui.set_phase(ReindexPhase::ParseEmbed, index_id);
}
Some("batch") => {
let indexed = evt.get("indexed").and_then(|v| v.as_u64()).unwrap_or(0);
let batch_chunks = evt
.get("batch_chunks")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let chunks_per_sec = evt
.get("chunks_per_sec")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let total = evt.get("total_files").and_then(|v| v.as_u64()).unwrap_or(0);
if total > 0 && ui.files.length() != Some(total.max(1)) {
ui.set_total(total);
}
indexed_now.store(indexed, Ordering::Release);
cps_now.store(chunks_per_sec, Ordering::Release);
let new_chunks =
chunks_now.fetch_add(batch_chunks, Ordering::AcqRel) + batch_chunks;
ui.set_position(indexed);
ui.update_stats(
indexed,
new_chunks,
skipped_now.load(Ordering::Acquire),
chunks_per_sec,
started.elapsed().as_secs(),
);
}
Some("skip") => {
let indexed = evt.get("indexed").and_then(|v| v.as_u64()).unwrap_or(0);
indexed_now.store(indexed, Ordering::Release);
let skipped = skipped_now.fetch_add(1, Ordering::AcqRel) + 1;
ui.set_position(indexed);
ui.update_stats(
indexed,
chunks_now.load(Ordering::Acquire),
skipped,
cps_now.load(Ordering::Acquire),
started.elapsed().as_secs(),
);
}
Some("complete") => {
outcome.indexed = evt.get("indexed").and_then(|v| v.as_u64()).unwrap_or(0);
outcome.total_chunks = evt
.get("total_chunks")
.and_then(|v| v.as_u64())
.unwrap_or(0);
outcome.skipped = evt
.get("skipped")
.and_then(|v| v.as_u64())
.unwrap_or_else(|| skipped_now.load(Ordering::Acquire));
outcome.errors = evt.get("errors").and_then(|v| v.as_u64()).unwrap_or(0);
outcome.elapsed_ms = evt.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
if let Some(t) = evt.get("timings") {
let get = |k: &str| t.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
outcome.timings = Some(ReindexTimings {
parse_ms: get("parse_ms"),
embed_ms: get("embed_ms"),
bm25_ms: get("bm25_ms"),
vector_upsert_ms: get("vector_upsert_ms"),
kg_ms: get("kg_ms"),
vector_count: get("vector_count"),
symbol_count: get("symbol_count"),
edge_count: get("edge_count"),
});
}
outcome.completed = true;
ui.set_position(outcome.indexed);
ui.update_stats(
outcome.indexed,
outcome.total_chunks,
outcome.skipped,
cps_now.load(Ordering::Acquire),
started.elapsed().as_secs(),
);
ui.set_phase(ReindexPhase::Done, index_id);
done = true;
}
Some("error") => {
let msg = evt
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let file = evt.get("file").and_then(|v| v.as_str()).unwrap_or("");
ui.stats
.println(format!("{} {}: {}", "⚠".yellow(), file, msg));
}
_ => {}
}
}
tick_done.store(true, Ordering::Release);
let _ = ticker.await;
if timed_out {
ui.abandon(format!(
"{} trusty-search index timed out after {}s — continuing; re-run later if needed",
"⚠".yellow(),
opts.timeout_secs,
));
eprintln!(
"{} Daemon is still indexing in the background. \
Use `trusty-search status` or re-run `trusty-search index` to check progress. \
Pass `--timeout <seconds>` to wait longer (e.g. `--timeout 1200`).",
"ℹ".cyan()
);
return Ok(outcome);
}
if !outcome.completed {
ui.abandon(format!(
"{} Reindex stream ended without completion event",
"⚠".yellow()
));
anyhow::bail!("reindex did not complete");
}
let elapsed = fmt_elapsed(outcome.elapsed_ms);
let changed = outcome.indexed.saturating_sub(outcome.skipped);
let final_msg = if outcome.errors > 0 {
format!(
"{} Indexed {} files → {} chunks [took {}, {} errors, {} unchanged]",
"✓".green(),
format_with_commas(changed),
format_with_commas(outcome.total_chunks),
elapsed,
outcome.errors,
format_with_commas(outcome.skipped),
)
} else if changed == 0 && outcome.indexed > 0 {
format!(
"{} '{}' is up to date ({} chunks, {} files — no changes detected) [took {}]",
"✓".green(),
index_id,
format_with_commas(outcome.total_chunks),
format_with_commas(outcome.indexed),
elapsed,
)
} else {
format!(
"{} Indexed {} changed file{} → {} chunks [took {}, {} unchanged]",
"✓".green(),
format_with_commas(changed),
if changed == 1 { "" } else { "s" },
format_with_commas(outcome.total_chunks),
elapsed,
format_with_commas(outcome.skipped),
)
};
ui.finish(final_msg);
if let Some(t) = outcome.timings {
print_timing_breakdown(&t, outcome.total_chunks);
}
if opts.verify_after {
verify_reindex_health(&client, &base, index_id, &outcome, opts.prior_chunk_count).await?;
}
Ok(outcome)
}
async fn verify_reindex_health(
client: &reqwest::Client,
base: &str,
index_id: &str,
outcome: &ReindexOutcome,
prior: Option<u64>,
) -> Result<()> {
let status_url = format!("{}/indexes/{}/status", base, index_id);
let new_chunks = match client.get(&status_url).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("chunk_count").and_then(|n| n.as_u64()))
.unwrap_or(0),
_ => 0,
};
let search_url = format!("{}/indexes/{}/search", base, index_id);
let probes = ["fn", "function", "def", "class", "the"];
let mut got_hit = false;
for probe in probes {
let body = serde_json::json!({ "text": probe, "top_k": 1 });
if let Ok(resp) = client.post(&search_url).json(&body).send().await {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let n = json
.get("results")
.and_then(|r| r.as_array())
.map(|a| a.len())
.unwrap_or(0);
if n > 0 {
got_hit = true;
break;
}
}
}
}
}
let healthy = new_chunks > 0 && got_hit && outcome.errors == 0;
let was = prior
.map(|p| format!(" (was {})", format_with_commas(p)))
.unwrap_or_default();
if healthy {
println!(
"{} Reindex complete: {} chunks{}",
"✓".green(),
format_with_commas(new_chunks),
was
);
Ok(())
} else {
anyhow::bail!(
"Reindex produced unhealthy index: {} chunks{}, sanity query {} — old index NOT preserved (daemon reindex is in-place; see crates/trusty-search-service/src/reindex.rs)",
format_with_commas(new_chunks),
was,
if got_hit { "ok" } else { "returned 0 results" }
);
}
}
pub async fn register_index_with_daemon(
index_name: &str,
project_path: &std::path::Path,
) -> Result<(bool, bool)> {
register_index_with_daemon_filtered(index_name, project_path, &RegisterFilters::default()).await
}
#[derive(Debug, Default)]
pub struct RegisterFilters {
pub include_paths: Vec<String>,
pub exclude_globs: Vec<String>,
pub extensions: Vec<String>,
pub domain_terms: Vec<String>,
}
pub async fn register_index_with_daemon_filtered(
index_name: &str,
project_path: &std::path::Path,
filters: &RegisterFilters,
) -> Result<(bool, bool)> {
let base = daemon_base_url();
let client = trusty_common::server::daemon_http_client()?;
let create_url = format!("{}/indexes", base);
let mut create_body = serde_json::json!({
"id": index_name,
"root_path": project_path,
});
if !filters.include_paths.is_empty() {
create_body["include_paths"] = serde_json::json!(filters.include_paths);
}
if !filters.exclude_globs.is_empty() {
create_body["exclude_globs"] = serde_json::json!(filters.exclude_globs);
}
if !filters.extensions.is_empty() {
create_body["extensions"] = serde_json::json!(filters.extensions);
}
if !filters.domain_terms.is_empty() {
create_body["domain_terms"] = serde_json::json!(filters.domain_terms);
}
match client.post(&create_url).json(&create_body).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value =
resp.json().await.unwrap_or_else(|_| serde_json::json!({}));
let created = body
.get("created")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok((created, true))
}
Ok(resp) => {
anyhow::bail!("daemon returned {} for POST /indexes", resp.status());
}
Err(_) => Ok((false, false)),
}
}
pub async fn fetch_chunk_count(index_id: &str) -> Option<u64> {
let base = daemon_base_url();
let url = format!("{}/indexes/{}/status", base, index_id);
let client = trusty_common::server::daemon_http_client().ok()?;
let resp = client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body.get("chunk_count").and_then(|v| v.as_u64())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase_labels_are_stable() {
assert_eq!(ReindexPhase::Connecting.label(), "Connecting to daemon…");
assert_eq!(
ReindexPhase::ParseEmbed.label(),
"Parsing & embedding files"
);
assert_eq!(ReindexPhase::Bm25.label(), "Building BM25 index…");
assert_eq!(
ReindexPhase::KnowledgeGraph.label(),
"Building knowledge graph…"
);
assert_eq!(ReindexPhase::Upsert.label(), "Upserting vectors…");
assert_eq!(ReindexPhase::Done.label(), "Done");
}
#[test]
fn files_bar_chunk_msg_formats_with_commas() {
assert_eq!(files_bar_chunk_msg(0), "0 chunks");
assert_eq!(files_bar_chunk_msg(42), "42 chunks");
assert_eq!(files_bar_chunk_msg(58_402), "58,402 chunks");
}
#[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::ParseEmbed, "test-index");
assert_eq!(ui.phase, ReindexPhase::ParseEmbed);
ui.set_total(100);
ui.set_position(50);
ui.update_stats(50, 4_096, 3, 128, 10);
ui.set_phase(ReindexPhase::Done, "test-index");
assert_eq!(ui.phase, ReindexPhase::Done);
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 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);
}
}