1use std::sync::Arc;
6
7use axum::{
8 extract::{Json, State},
9 http::StatusCode,
10 routing::{get, post},
11 Router,
12};
13use serde::{Deserialize, Serialize};
14use tokio_util::sync::CancellationToken;
15use tracing::info;
16
17use crate::fts::FtsStore;
18use crate::vectordb::VectorStore;
19
20use super::DaemonState;
21
22#[derive(Debug, Deserialize)]
25pub struct SearchRequest {
26 pub query: String,
27 #[serde(default = "default_limit")]
28 pub limit: usize,
29 #[serde(default)]
30 pub path: Option<String>,
31 #[serde(default)]
33 pub repo: Option<String>,
34}
35
36fn default_limit() -> usize {
37 25
38}
39
40#[derive(Debug, Serialize)]
41pub struct SearchResponse {
42 pub results: Vec<SearchResult>,
43 pub query: String,
44 pub took_ms: u64,
45}
46
47#[derive(Debug, Serialize)]
48pub struct SearchResult {
49 pub repo: String,
50 pub path: String,
51 pub content: String,
52 pub start_line: usize,
53 pub end_line: usize,
54 pub kind: String,
55 pub score: f32,
56}
57
58#[derive(Debug, Serialize)]
59pub struct HealthResponse {
60 pub status: String,
61 pub repos: Vec<RepoStatus>,
62}
63
64#[derive(Debug, Serialize)]
65pub struct RepoStatus {
66 pub name: String,
67 pub files: usize,
68 pub chunks: usize,
69}
70
71#[derive(Debug, Serialize)]
72pub struct ReposResponse {
73 pub repos: Vec<RepoInfo>,
74}
75
76#[derive(Debug, Serialize)]
77pub struct RepoInfo {
78 pub name: String,
79 pub path: String,
80 pub db_path: String,
81 pub files: usize,
82 pub chunks: usize,
83 pub indexed: bool,
84}
85
86pub async fn run_server(
89 state: Arc<DaemonState>,
90 port: u16,
91 cancel_token: CancellationToken,
92) -> anyhow::Result<()> {
93 let app = Router::new()
94 .route("/health", get(health_handler))
95 .route("/status", get(status_handler))
96 .route("/search", post(search_handler))
97 .route("/repos", get(repos_handler))
98 .with_state(state);
99
100 let addr = format!("127.0.0.1:{}", port);
101 info!("Daemon HTTP server listening on http://{}", addr);
102
103 let listener = tokio::net::TcpListener::bind(&addr).await?;
104
105 axum::serve(listener, app)
106 .with_graceful_shutdown(async move {
107 cancel_token.cancelled().await;
108 info!("HTTP server shutting down");
109 })
110 .await?;
111
112 Ok(())
113}
114
115async fn health_handler(State(state): State<Arc<DaemonState>>) -> Json<HealthResponse> {
118 let mut repos = Vec::new();
119
120 for repo in &state.repos {
121 let vs: tokio::sync::RwLockReadGuard<'_, VectorStore> =
122 repo.stores.vector_store.read().await;
123 let stats = vs.stats().unwrap_or(crate::vectordb::StoreStats {
124 total_chunks: 0,
125 total_files: 0,
126 indexed: false,
127 dimensions: 0,
128 max_chunk_id: 0,
129 });
130
131 repos.push(RepoStatus {
132 name: repo.name.clone(),
133 files: stats.total_files,
134 chunks: stats.total_chunks,
135 });
136 }
137
138 Json(HealthResponse {
139 status: "ready".to_string(),
140 repos,
141 })
142}
143
144async fn status_handler(State(state): State<Arc<DaemonState>>) -> Json<HealthResponse> {
145 health_handler(State(state)).await
147}
148
149async fn repos_handler(State(state): State<Arc<DaemonState>>) -> Json<ReposResponse> {
150 let mut repos = Vec::new();
151
152 for repo in &state.repos {
153 let vs: tokio::sync::RwLockReadGuard<'_, VectorStore> =
154 repo.stores.vector_store.read().await;
155 let stats = vs.stats().unwrap_or(crate::vectordb::StoreStats {
156 total_chunks: 0,
157 total_files: 0,
158 indexed: false,
159 dimensions: 0,
160 max_chunk_id: 0,
161 });
162
163 repos.push(RepoInfo {
164 name: repo.name.clone(),
165 path: repo.project_path.display().to_string(),
166 db_path: repo.db_path.display().to_string(),
167 files: stats.total_files,
168 chunks: stats.total_chunks,
169 indexed: stats.indexed,
170 });
171 }
172
173 Json(ReposResponse { repos })
174}
175
176async fn search_handler(
177 State(state): State<Arc<DaemonState>>,
178 Json(req): Json<SearchRequest>,
179) -> Result<Json<SearchResponse>, (StatusCode, String)> {
180 let start = std::time::Instant::now();
181
182 let query_embedding = {
184 let mut es = state.embedding_service.lock().await;
185 es.embed_query(&req.query)
186 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
187 };
188
189 let mut all_results: Vec<SearchResult> = Vec::new();
191
192 for repo in &state.repos {
193 if let Some(ref filter) = req.repo {
195 if &repo.name != filter {
196 continue;
197 }
198 }
199
200 let vector_results = {
202 let vs: tokio::sync::RwLockReadGuard<'_, VectorStore> =
203 repo.stores.vector_store.read().await;
204 vs.search(&query_embedding, req.limit)
205 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
206 };
207
208 let fts_results = {
210 let fts: tokio::sync::RwLockReadGuard<'_, FtsStore> =
211 repo.stores.fts_store.read().await;
212 fts.search(&req.query, req.limit, None)
213 .unwrap_or_default()
214 };
215
216 let fused = crate::rerank::rrf_fusion(
218 &vector_results,
219 &fts_results,
220 crate::rerank::DEFAULT_RRF_K,
221 );
222
223 let vs: tokio::sync::RwLockReadGuard<'_, VectorStore> =
225 repo.stores.vector_store.read().await;
226 for fused_result in &fused {
227 if let Ok(Some(chunk)) = vs.get_chunk(fused_result.chunk_id) {
228 if let Some(ref path_filter) = req.path {
230 if !chunk.path.contains(path_filter) {
231 continue;
232 }
233 }
234
235 let rel_path = chunk
237 .path
238 .strip_prefix(repo.project_path.to_str().unwrap_or(""))
239 .unwrap_or(&chunk.path)
240 .trim_start_matches('/')
241 .to_string();
242
243 all_results.push(SearchResult {
244 repo: repo.name.clone(),
245 path: rel_path,
246 content: truncate_content(&chunk.content, 500),
247 start_line: chunk.start_line,
248 end_line: chunk.end_line,
249 kind: chunk.kind.clone(),
250 score: fused_result.rrf_score,
251 });
252 }
253 }
254 }
255
256 all_results.sort_by(|a, b| {
258 b.score
259 .partial_cmp(&a.score)
260 .unwrap_or(std::cmp::Ordering::Equal)
261 });
262 all_results.truncate(req.limit);
263
264 let took_ms = start.elapsed().as_millis() as u64;
265
266 Ok(Json(SearchResponse {
267 results: all_results,
268 query: req.query,
269 took_ms,
270 }))
271}
272
273fn truncate_content(content: &str, max_len: usize) -> String {
274 if content.len() <= max_len {
275 content.to_string()
276 } else {
277 format!("{}...", &content[..max_len])
278 }
279}