use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::core::registry::{IndexHandle, IndexId};
use super::helpers::file_is_within_root;
use super::router::{IndexFileRequest, RemoveFileRequest};
use super::state::SearchAppState;
pub(super) async fn index_file_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Json(req): Json<IndexFileRequest>,
) -> 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;
indexer
.index_file(&req.path, &req.content)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::json!({
"index_id": index_id.0,
"path": req.path,
"indexed": true,
})))
}
pub(super) async fn remove_file_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Json(req): Json<RemoveFileRequest>,
) -> 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 removed = indexer
.remove_file(&req.path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::json!({
"index_id": index_id.0,
"path": req.path,
"removed_chunks": removed,
})))
}
#[derive(Deserialize)]
pub struct ChunksParams {
#[serde(default)]
pub offset: usize,
#[serde(default = "default_chunks_limit")]
pub limit: usize,
}
fn default_chunks_limit() -> usize {
100
}
const MAX_CHUNKS_LIMIT: usize = 1_000;
pub(super) async fn get_index_chunks_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Query(params): Query<ChunksParams>,
) -> 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 limit = params.limit.min(MAX_CHUNKS_LIMIT);
let indexer = handle.indexer.read().await;
let (total, chunks) = indexer.enumerate_chunks(params.offset, limit).await;
Ok(Json(serde_json::json!({
"index_id": index_id.0,
"total": total,
"offset": params.offset,
"limit": limit,
"chunks": chunks,
})))
}
async fn grep_one_index(
handle: &IndexHandle,
compiled: &crate::service::grep::CompiledGrep,
out: &mut Vec<crate::service::grep::GrepMatch>,
max_results: usize,
) {
if out.len() >= max_results {
return;
}
let chunks = {
let indexer = handle.indexer.read().await;
indexer.raw_chunks_snapshot().await
};
let mut files: Vec<String> = chunks.into_iter().map(|c| c.file).collect();
files.sort();
files.dedup();
for rel in files {
if out.len() >= max_results {
return;
}
if !compiled.path_matches(&rel) {
continue;
}
if !file_is_within_root(&rel, &handle.root_path) {
continue;
}
let abs = if std::path::Path::new(&rel).is_absolute() {
std::path::PathBuf::from(&rel)
} else {
handle.root_path.join(&rel)
};
match tokio::fs::read_to_string(&abs).await {
Ok(content) => {
crate::service::grep::grep_file_content(&rel, &content, compiled, out, max_results);
}
Err(e) => {
tracing::debug!(
file = %rel,
error = %e,
"grep: skipping unreadable file (deleted or non-UTF-8 since index time)"
);
}
}
}
}
pub(super) async fn grep_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Json(req): Json<crate::service::grep::GrepRequest>,
) -> Result<Json<crate::service::grep::GrepResponse>, (StatusCode, Json<serde_json::Value>)> {
if req.pattern.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "pattern must not be empty" })),
));
}
let compiled = crate::service::grep::CompiledGrep::compile(&req).map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
let index_id = IndexId::new(id);
let handle = state.registry.get(&index_id).ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("unknown index: {}", index_id.0) })),
))?;
let started = std::time::Instant::now();
let mut matches = Vec::new();
grep_one_index(&handle, &compiled, &mut matches, req.max_results).await;
let truncated = matches.len() >= req.max_results;
tracing::info!(
index_id = %index_id,
matches = matches.len(),
truncated = truncated,
latency_ms = started.elapsed().as_millis() as u64,
"grep"
);
let total = matches.len();
Ok(Json(crate::service::grep::GrepResponse {
matches,
total,
truncated,
}))
}
pub(super) async fn global_grep_handler(
State(state): State<Arc<SearchAppState>>,
Json(req): Json<crate::service::grep::GrepRequest>,
) -> Result<Json<crate::service::grep::GrepResponse>, (StatusCode, Json<serde_json::Value>)> {
if req.pattern.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "pattern must not be empty" })),
));
}
let compiled = crate::service::grep::CompiledGrep::compile(&req).map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
let ids: Vec<IndexId> = match req.index_id.as_deref() {
Some(only) => state
.registry
.list()
.into_iter()
.filter(|id| id.0 == only)
.collect(),
None => state.registry.list(),
};
let started = std::time::Instant::now();
let mut matches = Vec::new();
for id in ids {
if matches.len() >= req.max_results {
break;
}
if let Some(handle) = state.registry.get(&id) {
grep_one_index(&handle, &compiled, &mut matches, req.max_results).await;
}
}
let truncated = matches.len() >= req.max_results;
tracing::info!(
matches = matches.len(),
truncated = truncated,
latency_ms = started.elapsed().as_millis() as u64,
"grep_global"
);
let total = matches.len();
Ok(Json(crate::service::grep::GrepResponse {
matches,
total,
truncated,
}))
}
#[derive(Debug, Deserialize)]
pub(super) struct CallChainParams {
entry_point: String,
direction: Option<String>,
max_depth: Option<u32>,
include_source: Option<bool>,
}
pub(super) async fn call_chain_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Query(params): Query<CallChainParams>,
) -> Result<Response, (StatusCode, axum::Json<serde_json::Value>)> {
use crate::service::call_chain::{render_call_chain, CallChainRequest};
let req = CallChainRequest {
index_id: id.clone(),
entry_point: params.entry_point,
direction: params.direction,
max_depth: params.max_depth,
include_source: params.include_source,
};
let validated = req.validate().map_err(|e| {
(
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
let index_id = IndexId::new(id);
let handle = state.registry.get(&index_id).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({ "error": format!("unknown index: {}", index_id.0) })),
)
})?;
if handle.skip_kg {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(serde_json::json!({
"error": "kg_unavailable",
"reason": "skipped_by_config",
"index": index_id.0,
})),
));
}
let (graph, chunks) = {
let indexer = handle.indexer.read().await;
let graph = indexer.snapshot_symbol_graph().await;
let chunks = indexer.raw_chunks_snapshot().await;
(graph, chunks)
};
let text = render_call_chain(&validated, graph.as_ref(), &chunks).map_err(|e| {
(
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({ "error": e })),
)
})?;
Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
text,
)
.into_response())
}