Skip to main content

codesearch/daemon/
server.rs

1//! Multi-repo HTTP server for the daemon.
2//!
3//! Fan-out search across all managed repos, merge results by RRF score.
4
5use 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// ── Request / Response types ─────────────────────────────────────────
23
24#[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    /// Filter to a specific repo by name
32    #[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
86// ── Server ───────────────────────────────────────────────────────────
87
88pub 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
115// ── Handlers ─────────────────────────────────────────────────────────
116
117async 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    // Same as health for now
146    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    // Embed query once
183    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    // Fan-out search across all repos (or filtered repo)
190    let mut all_results: Vec<SearchResult> = Vec::new();
191
192    for repo in &state.repos {
193        // Filter by repo name if requested
194        if let Some(ref filter) = req.repo {
195            if &repo.name != filter {
196                continue;
197            }
198        }
199
200        // Vector search
201        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        // FTS search
209        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        // RRF fusion per repo
217        let fused = crate::rerank::rrf_fusion(
218            &vector_results,
219            &fts_results,
220            crate::rerank::DEFAULT_RRF_K,
221        );
222
223        // Resolve chunk metadata and build results
224        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                // Filter by path if requested
229                if let Some(ref path_filter) = req.path {
230                    if !chunk.path.contains(path_filter) {
231                        continue;
232                    }
233                }
234
235                // Make path relative to repo root
236                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    // Sort all results by score descending, then truncate to limit
257    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}