use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use std::sync::Arc;
use crate::core::registry::IndexId;
use crate::service::reindex::ReindexStatus;
use super::state::SearchAppState;
pub(super) fn index_disk_and_mtime(index_id: &str) -> (Option<u64>, Option<String>) {
let Ok(data_dir) = crate::service::persistence::data_dir() else {
return (None, None);
};
let dir = data_dir
.join("indexes")
.join(crate::service::persistence::sanitize_id_for_path(index_id));
if !dir.exists() {
return (None, None);
}
let disk_bytes = Some(trusty_common::sys_metrics::dir_size_bytes(&dir));
let last_indexed = first_existing_mtime_rfc3339(&dir, &["index.redb", "chunks.json"]);
(disk_bytes, last_indexed)
}
pub(super) fn first_existing_mtime_rfc3339(
dir: &std::path::Path,
candidates: &[&str],
) -> Option<String> {
candidates
.iter()
.find_map(|name| std::fs::metadata(dir.join(name)).ok())
.and_then(|m| m.modified().ok())
.map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
})
}
pub(super) async fn index_status_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let index_id = IndexId::new(id);
let handle = state.registry.get(&index_id).ok_or(StatusCode::NOT_FOUND)?;
let indexer = handle.indexer.read().await;
let path_filter = if handle.path_filter.is_empty() {
serde_json::Value::Null
} else {
serde_json::Value::Array(
handle
.path_filter
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
)
};
let has_context_embedding = handle.context_embedding.read().await.is_some();
let context_summary = handle
.context_summary
.read()
.await
.clone()
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null);
let (disk_bytes, disk_last_indexed) = index_disk_and_mtime(&index_id.0);
let in_memory_last_indexed = handle.last_indexed_at.read().await.clone();
let last_indexed = in_memory_last_indexed.or(disk_last_indexed);
let legacy_status = match state
.reindex_progress
.get(&index_id)
.map(|p| p.status.load())
{
Some(ReindexStatus::Running) => "indexing",
_ => "ready",
};
let stages_snapshot = handle.stages.read().await.clone();
let search_capabilities = stages_snapshot.search_capabilities();
let (walk_truncated_by_budget, chunks_dropped_by_cap) = state
.reindex_progress
.get(&index_id)
.map_or((false, 0), |p| {
let n = p
.chunks_dropped_by_cap
.load(std::sync::atomic::Ordering::Acquire);
(n > 0, n)
});
let walk_diag = handle.walk_diagnostics.read().await.clone();
let chunk_count = indexer
.corpus_arc()
.and_then(|c| c.chunk_count().ok())
.unwrap_or_else(|| indexer.chunk_count());
Ok(Json(serde_json::json!({
"index_id": index_id.0,
"root_path": handle.root_path,
"chunk_count": chunk_count,
"status": legacy_status,
"stages": stages_snapshot,
"search_capabilities": search_capabilities,
"lexical_only": handle.lexical_only,
"skip_kg": handle.skip_kg,
"path_filter": path_filter,
"has_context_embedding": has_context_embedding,
"context_summary": context_summary,
"disk_bytes": disk_bytes,
"last_indexed": last_indexed,
"respect_gitignore": handle.respect_gitignore,
"walk_truncated_by_budget": walk_truncated_by_budget,
"chunks_dropped_by_cap": chunks_dropped_by_cap,
"last_walk_started_at": walk_diag.last_walk_started_at,
"last_walk_files_seen": walk_diag.last_walk_files_seen,
"last_walk_files_skipped": walk_diag.last_walk_files_skipped,
"last_walk_error": walk_diag.last_walk_error,
})))
}
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct GraphQueryParams {
pub(super) types: Option<String>,
pub(super) edge_types: Option<String>,
pub(super) min_weight: Option<f32>,
}
fn parse_filter_set(raw: Option<&str>) -> Option<std::collections::HashSet<String>> {
let raw = raw?;
let set: std::collections::HashSet<String> = raw
.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
if set.is_empty() {
None
} else {
Some(set)
}
}
fn node_type_for_symbol(symbol: &str) -> &'static str {
let looks_like_path = symbol.contains('/')
&& std::path::Path::new(symbol)
.extension()
.is_some_and(|e| !e.is_empty());
if looks_like_path {
"File"
} else {
"Symbol"
}
}
pub(super) async fn graph_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Query(params): Query<GraphQueryParams>,
) -> Result<Response, StatusCode> {
let index_id = IndexId::new(id);
let handle = state.registry.get(&index_id).ok_or(StatusCode::NOT_FOUND)?;
let graph = {
let indexer = handle.indexer.read().await;
indexer.snapshot_symbol_graph().await
};
let type_filter = parse_filter_set(params.types.as_deref());
let edge_filter = parse_filter_set(params.edge_types.as_deref());
let min_weight = params.min_weight.unwrap_or(f32::MIN);
let mut kept_symbols: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut nodes: Vec<serde_json::Value> = Vec::new();
for (symbol, chunk_id, file) in graph.all_nodes() {
let node_type = node_type_for_symbol(&symbol);
if let Some(ref filter) = type_filter {
if !filter.contains(&node_type.to_ascii_lowercase()) {
continue;
}
}
kept_symbols.insert(symbol.clone());
nodes.push(serde_json::json!({
"id": chunk_id,
"type": node_type,
"label": symbol,
"metadata": { "file": file, "symbol": symbol },
}));
}
let mut edges: Vec<serde_json::Value> = Vec::new();
for (source, target, kind) in graph.all_edges() {
if type_filter.is_some()
&& (!kept_symbols.contains(&source) || !kept_symbols.contains(&target))
{
continue;
}
let kind_name = format!("{kind:?}");
if let Some(ref filter) = edge_filter {
if !filter.contains(&kind_name.to_ascii_lowercase()) {
continue;
}
}
let weight = kind.score_multiplier();
if weight < min_weight {
continue;
}
edges.push(serde_json::json!({
"source": source,
"target": target,
"type": kind_name,
"weight": weight,
}));
}
let body = serde_json::json!({
"nodes": nodes,
"edges": edges,
"stats": {
"node_count": graph.node_count(),
"edge_count": graph.edge_count(),
},
"generated_at": chrono::Utc::now().to_rfc3339(),
});
let mut response = Json(body).into_response();
response.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("max-age=3600"),
);
Ok(response)
}
pub(super) async fn graph_stats_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let index_id = IndexId::new(id);
let handle = state.registry.get(&index_id).ok_or(StatusCode::NOT_FOUND)?;
let graph = {
let indexer = handle.indexer.read().await;
indexer.snapshot_symbol_graph().await
};
let breakdown = graph.edge_kind_breakdown();
let mut edge_kinds = serde_json::Map::with_capacity(breakdown.len());
for (tag, count) in breakdown {
edge_kinds.insert(tag, serde_json::Value::from(count));
}
Ok(Json(serde_json::json!({
"node_count": graph.node_count(),
"edge_count": graph.edge_count(),
"edge_kinds": serde_json::Value::Object(edge_kinds),
})))
}