use super::daemon_utils::daemon_base_url;
use super::format::format_with_commas;
use anyhow::Result;
use colored::Colorize;
use std::io::IsTerminal;
use std::time::Duration;
pub async fn handle_index_status(index_id: Option<&str>, watch: bool, json: bool) -> Result<()> {
if watch && json {
anyhow::bail!("`--watch` and `--json` cannot be used together");
}
let base = daemon_base_url();
crate::commands::daemon_guard::ensure_daemon_running_or_exit(&base).await?;
let client = trusty_common::server::daemon_http_client()?;
match index_id {
Some(id) => {
let url = format!("{}/indexes/{}/status", base, id);
run_status_for_single(id, &url, &client, watch, json).await
}
None => {
run_status_for_cwd(&client, &base, watch, json).await
}
}
}
async fn run_status_for_cwd(
client: &reqwest::Client,
base: &str,
watch: bool,
json: bool,
) -> Result<()> {
use super::index_cwd_resolve::resolve_cwd_indexes;
let matches = resolve_cwd_indexes(client, base).await?;
match matches.len() {
0 => {
let cwd = std::env::current_dir().unwrap_or_default();
eprintln!(
"{} no trusty-search index registered for {} — \
run 'trusty-search index .' to create one",
"✗".red(),
cwd.display()
);
anyhow::bail!("no index registered for current directory");
}
1 => {
let m = &matches[0];
let url = format!("{}/indexes/{}/status", base, m.id);
run_status_for_single(&m.id, &url, client, watch, json).await
}
_ => {
if watch {
eprintln!(
"{} --watch requires an explicit index id when multiple indexes \
cover the current directory. Candidates:",
"✗".red()
);
for m in &matches {
eprintln!(" {} ({})", m.id.bold(), m.root_path.display());
}
eprintln!("Re-run: trusty-search index-status <id> --watch");
anyhow::bail!(
"--watch requires an explicit index id when multiple indexes cover cwd"
);
}
for m in &matches {
if json {
println!("{}", serde_json::to_string_pretty(&m.status_body)?);
} else {
print_status_table(&m.id, &m.status_body);
println!(); }
}
Ok(())
}
}
}
async fn run_status_for_single(
index_id: &str,
url: &str,
client: &reqwest::Client,
watch: bool,
json: bool,
) -> Result<()> {
if !watch {
let body = fetch_status(client, url).await?;
if json {
println!("{}", serde_json::to_string_pretty(&body)?);
} else {
print_status_table(index_id, &body);
}
return Ok(());
}
let is_tty = std::io::stdout().is_terminal();
loop {
let body = fetch_status(client, url).await?;
let semantic_status = body
.get("stages")
.and_then(|s| s.get("semantic"))
.and_then(|se| se.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("pending");
if json {
println!("{}", serde_json::to_string_pretty(&body)?);
} else if is_tty {
print_status_table_tty_clear(index_id, &body);
} else {
print_status_line_nontty(index_id, &body);
}
if semantic_status == "ready" || semantic_status == "failed" {
if is_tty && !json {
println!();
}
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
Ok(())
}
async fn fetch_status(client: &reqwest::Client, url: &str) -> Result<serde_json::Value> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| anyhow::anyhow!("could not reach daemon: {e}"))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"index not found — run `trusty-search index` to register it first, \
or `trusty-search list` to see registered indexes"
);
}
if !resp.status().is_success() {
anyhow::bail!("daemon returned {} for status query", resp.status());
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("could not parse status response: {e}"))?;
Ok(body)
}
const WATCH_TABLE_LINES: usize = 4;
pub fn print_status_table(index_id: &str, body: &serde_json::Value) {
let root = body.get("root_path").and_then(|v| v.as_str()).unwrap_or("");
println!(" {} {}", index_id.bold(), root.dimmed());
if let Some(stages) = body.get("stages") {
print_stage_row("lexical ", stages.get("lexical"));
print_stage_row("semantic", stages.get("semantic"));
print_stage_row("graph ", stages.get("graph"));
}
}
fn print_status_table_tty_clear(index_id: &str, body: &serde_json::Value) {
print!("\x1b[{WATCH_TABLE_LINES}F\x1b[0J");
print_status_table(index_id, body);
}
fn print_status_line_nontty(index_id: &str, body: &serde_json::Value) {
let now = chrono::Utc::now().format("%H:%M:%S").to_string();
let sem = body
.get("stages")
.and_then(|s| s.get("semantic"))
.cloned()
.unwrap_or(serde_json::json!({}));
let status = sem.get("status").and_then(|v| v.as_str()).unwrap_or("?");
let embedded = sem.get("embedded").and_then(|v| v.as_u64()).unwrap_or(0);
let total = sem.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
let pct = (embedded * 100).checked_div(total).unwrap_or(0);
if total > 0 {
println!(
"{now} {index_id} semantic={status} {}/{} ({pct}%)",
format_with_commas(embedded),
format_with_commas(total),
);
} else {
println!("{now} {index_id} semantic={status}");
}
}
pub fn truncate_reason(msg: &str) -> String {
if msg.len() > 80 {
format!("{}…", &msg[..79])
} else {
msg.to_string()
}
}
pub fn print_stage_row(label: &str, stage: Option<&serde_json::Value>) {
let stage = match stage {
Some(s) => s,
None => {
println!(" {} {}", label, "unknown".dimmed());
return;
}
};
let status = stage.get("status").and_then(|v| v.as_str()).unwrap_or("?");
let colored_status = colorize_status(status);
let embedded = stage.get("embedded").and_then(|v| v.as_u64());
let total = stage.get("total").and_then(|v| v.as_u64());
let failure = stage
.get("failure")
.and_then(|v| v.as_str())
.map(truncate_reason);
match (embedded, total, failure) {
(Some(emb), Some(tot), _) if tot > 0 => {
let pct = (emb * 100).checked_div(tot).unwrap_or(0);
println!(
" {} {} {}/{} chunks ({}%)",
label.bold(),
colored_status,
format_with_commas(emb),
format_with_commas(tot),
pct,
);
}
(_, _, Some(reason)) => {
println!(
" {} {} {}",
label.bold(),
colored_status,
reason.red()
);
}
_ => {
println!(" {} {}", label.bold(), colored_status);
}
}
}
pub fn colorize_status(status: &str) -> colored::ColoredString {
match status {
"ready" => "ready".green(),
"in_progress" => "embedding".yellow(),
"failed" => "failed".red(),
"pending" => "pending".dimmed(),
"skipped" => "skipped".dimmed(),
other => other.bold(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colorize_status_maps_known_values() {
colored::control::set_override(false);
assert_eq!(colorize_status("ready").to_string(), "ready");
assert_eq!(colorize_status("in_progress").to_string(), "embedding");
assert_eq!(colorize_status("failed").to_string(), "failed");
assert_eq!(colorize_status("pending").to_string(), "pending");
assert_eq!(colorize_status("skipped").to_string(), "skipped");
assert_eq!(colorize_status("unknown").to_string(), "unknown");
}
#[test]
fn embed_progress_pct_arithmetic() {
colored::control::set_override(false);
let embedded: u64 = 62_914;
let total: u64 = 152_616;
let pct = embedded * 100 / total;
assert_eq!(pct, 41, "percentage must be 41% for 62914/152616");
assert_eq!(format_with_commas(embedded), "62,914");
assert_eq!(format_with_commas(total), "152,616");
}
#[test]
fn failure_message_truncated_at_80_chars() {
let long_msg = "x".repeat(200);
let truncated = truncate_reason(&long_msg);
assert_eq!(
truncated.chars().count(),
80,
"truncated string must be 79 chars + ellipsis = 80 display columns"
);
assert!(truncated.ends_with('…'), "must end with ellipsis character");
let short_msg = "connection refused";
let result = truncate_reason(short_msg);
assert_eq!(result, short_msg, "short messages must not be modified");
let exact_msg = "y".repeat(80);
let result = truncate_reason(&exact_msg);
assert_eq!(result, exact_msg, "80-char messages must not be truncated");
}
#[tokio::test]
async fn watch_plus_json_is_rejected() {
let err = handle_index_status(Some("some-index"), true, true)
.await
.expect_err("`--watch` + `--json` must be rejected");
let msg = err.to_string();
assert!(
msg.contains("--watch") && msg.contains("--json"),
"error message must name both flags, got: {msg}"
);
}
#[test]
fn embed_progress_pct_zero_total_guard() {
let embedded: u64 = 0;
let total: u64 = 0;
let pct = (embedded * 100).checked_div(total).unwrap_or(0);
assert_eq!(
pct, 0,
"pct must be 0 when total is 0 (checked_div returns None)"
);
}
}