1use std::path::Path;
9use std::sync::Arc;
10use std::time::Instant;
11
12use axum::extract::State;
13use axum::http::StatusCode;
14use axum::routing::{get, post};
15use axum::{Json, Router};
16use serde::{Deserialize, Serialize};
17use tokio::net::TcpListener;
18
19use crate::config::SeekrConfig;
20use crate::embedder::batch::{BatchEmbedder, DummyEmbedder};
21use crate::embedder::traits::Embedder;
22use crate::index::store::SeekrIndex;
23use crate::parser::chunker::chunk_file_from_path;
24use crate::parser::summary::generate_summary;
25use crate::parser::CodeChunk;
26use crate::scanner::filter::should_index_file;
27use crate::scanner::walker::walk_directory;
28use crate::search::ast_pattern::search_ast_pattern;
29use crate::search::fusion::{fuse_ast_only, fuse_semantic_only, fuse_text_only, rrf_fuse};
30use crate::search::semantic::{search_semantic, SemanticSearchOptions};
31use crate::search::text::{search_text_regex, TextSearchOptions};
32use crate::search::{SearchMode, SearchQuery, SearchResponse, SearchResult};
33
34pub struct AppState {
36 pub config: SeekrConfig,
37}
38
39#[derive(Debug, Deserialize)]
45pub struct SearchRequest {
46 pub query: String,
48
49 #[serde(default = "default_mode")]
51 pub mode: String,
52
53 #[serde(default = "default_top_k")]
55 pub top_k: usize,
56
57 #[serde(default = "default_path")]
59 pub project_path: String,
60}
61
62fn default_mode() -> String {
63 "hybrid".to_string()
64}
65fn default_top_k() -> usize {
66 20
67}
68fn default_path() -> String {
69 ".".to_string()
70}
71
72#[derive(Debug, Deserialize)]
74pub struct IndexRequest {
75 #[serde(default = "default_path")]
77 pub path: String,
78
79 #[serde(default)]
81 pub force: bool,
82}
83
84#[derive(Debug, Serialize)]
86pub struct IndexResponse {
87 pub status: String,
88 pub project: String,
89 pub chunks: usize,
90 pub files_parsed: usize,
91 pub embedding_dim: usize,
92 pub duration_ms: u128,
93}
94
95#[derive(Debug, Deserialize)]
97pub struct StatusQuery {
98 #[serde(default = "default_path")]
100 pub path: String,
101}
102
103#[derive(Debug, Serialize)]
105pub struct StatusResponse {
106 pub indexed: bool,
107 pub project: String,
108 pub index_dir: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub chunks: Option<usize>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub embedding_dim: Option<usize>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub version: Option<u32>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub error: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub message: Option<String>,
119}
120
121#[derive(Debug, Serialize)]
123pub struct ErrorResponse {
124 pub error: String,
125 pub details: Option<String>,
126}
127
128pub async fn start_http_server(
134 host: &str,
135 port: u16,
136 config: SeekrConfig,
137) -> Result<(), crate::error::ServerError> {
138 let state = Arc::new(AppState { config });
139
140 let app = Router::new()
141 .route("/search", post(handle_search))
142 .route("/index", post(handle_index))
143 .route("/status", get(handle_status))
144 .route("/health", get(handle_health))
145 .with_state(state);
146
147 let addr = format!("{}:{}", host, port);
148 tracing::info!(address = %addr, "Starting HTTP server");
149
150 let listener = TcpListener::bind(&addr).await.map_err(|e| {
151 crate::error::ServerError::BindFailed {
152 address: addr.clone(),
153 source: e,
154 }
155 })?;
156
157 tracing::info!(address = %addr, "HTTP server listening");
158
159 axum::serve(listener, app)
160 .await
161 .map_err(|e| crate::error::ServerError::Internal(format!("Server error: {}", e)))?;
162
163 Ok(())
164}
165
166async fn handle_health() -> Json<serde_json::Value> {
172 Json(serde_json::json!({
173 "status": "ok",
174 "version": crate::VERSION,
175 }))
176}
177
178async fn handle_search(
180 State(state): State<Arc<AppState>>,
181 Json(req): Json<SearchRequest>,
182) -> Result<Json<SearchResponse>, (StatusCode, Json<ErrorResponse>)> {
183 let config = &state.config;
184 let start = Instant::now();
185
186 let search_mode: SearchMode = req.mode.parse().map_err(|e: String| {
188 (
189 StatusCode::BAD_REQUEST,
190 Json(ErrorResponse {
191 error: "Invalid search mode".to_string(),
192 details: Some(e),
193 }),
194 )
195 })?;
196
197 let project_path = Path::new(&req.project_path)
199 .canonicalize()
200 .unwrap_or_else(|_| Path::new(&req.project_path).to_path_buf());
201
202 let index_dir = config.project_index_dir(&project_path);
204 let index = SeekrIndex::load(&index_dir).map_err(|e| {
205 (
206 StatusCode::NOT_FOUND,
207 Json(ErrorResponse {
208 error: "Index not found".to_string(),
209 details: Some(format!(
210 "No index at {}. Run `seekr-code index` first. Error: {}",
211 index_dir.display(),
212 e,
213 )),
214 }),
215 )
216 })?;
217
218 let top_k = req.top_k;
220 let fused_results = match &search_mode {
221 SearchMode::Text => {
222 let options = TextSearchOptions {
223 case_sensitive: false,
224 context_lines: config.search.context_lines,
225 top_k,
226 };
227 let text_results = search_text_regex(&index, &req.query, &options).map_err(|e| {
228 (
229 StatusCode::BAD_REQUEST,
230 Json(ErrorResponse {
231 error: "Search failed".to_string(),
232 details: Some(e.to_string()),
233 }),
234 )
235 })?;
236 fuse_text_only(&text_results, top_k)
237 }
238 SearchMode::Semantic => {
239 let embedder = create_embedder(config).map_err(|e| {
240 (
241 StatusCode::INTERNAL_SERVER_ERROR,
242 Json(ErrorResponse {
243 error: "Embedder unavailable".to_string(),
244 details: Some(e.to_string()),
245 }),
246 )
247 })?;
248 let options = SemanticSearchOptions {
249 top_k,
250 score_threshold: config.search.score_threshold,
251 };
252 let results = search_semantic(&index, &req.query, embedder.as_ref(), &options)
253 .map_err(|e| {
254 (
255 StatusCode::INTERNAL_SERVER_ERROR,
256 Json(ErrorResponse {
257 error: "Semantic search failed".to_string(),
258 details: Some(e.to_string()),
259 }),
260 )
261 })?;
262 fuse_semantic_only(&results, top_k)
263 }
264 SearchMode::Hybrid => {
265 let text_options = TextSearchOptions {
266 case_sensitive: false,
267 context_lines: config.search.context_lines,
268 top_k,
269 };
270 let text_results =
271 search_text_regex(&index, &req.query, &text_options).map_err(|e| {
272 (
273 StatusCode::BAD_REQUEST,
274 Json(ErrorResponse {
275 error: "Text search failed".to_string(),
276 details: Some(e.to_string()),
277 }),
278 )
279 })?;
280
281 let embedder = create_embedder(config).map_err(|e| {
282 (
283 StatusCode::INTERNAL_SERVER_ERROR,
284 Json(ErrorResponse {
285 error: "Embedder unavailable".to_string(),
286 details: Some(e.to_string()),
287 }),
288 )
289 })?;
290 let semantic_options = SemanticSearchOptions {
291 top_k,
292 score_threshold: config.search.score_threshold,
293 };
294 let semantic_results =
295 search_semantic(&index, &req.query, embedder.as_ref(), &semantic_options)
296 .map_err(|e| {
297 (
298 StatusCode::INTERNAL_SERVER_ERROR,
299 Json(ErrorResponse {
300 error: "Semantic search failed".to_string(),
301 details: Some(e.to_string()),
302 }),
303 )
304 })?;
305
306 rrf_fuse(
307 &text_results,
308 &semantic_results,
309 config.search.rrf_k,
310 top_k,
311 )
312 }
313 SearchMode::Ast => {
314 let ast_results =
315 search_ast_pattern(&index, &req.query, top_k).map_err(|e| {
316 (
317 StatusCode::BAD_REQUEST,
318 Json(ErrorResponse {
319 error: "AST pattern search failed".to_string(),
320 details: Some(e.to_string()),
321 }),
322 )
323 })?;
324 fuse_ast_only(&ast_results, top_k)
325 }
326 };
327
328 let elapsed = start.elapsed();
329
330 let results: Vec<SearchResult> = fused_results
332 .iter()
333 .filter_map(|fused| {
334 index.get_chunk(fused.chunk_id).map(|chunk| SearchResult {
335 chunk: chunk.clone(),
336 score: fused.fused_score,
337 source: search_mode.clone(),
338 matched_lines: fused.matched_lines.clone(),
339 })
340 })
341 .collect();
342
343 let total = results.len();
344
345 let response = SearchResponse {
346 results,
347 total,
348 duration_ms: elapsed.as_millis() as u64,
349 query: SearchQuery {
350 query: req.query,
351 mode: search_mode,
352 top_k,
353 project_path: project_path.display().to_string(),
354 },
355 };
356
357 Ok(Json(response))
358}
359
360async fn handle_index(
362 State(state): State<Arc<AppState>>,
363 Json(req): Json<IndexRequest>,
364) -> Result<Json<IndexResponse>, (StatusCode, Json<ErrorResponse>)> {
365 let config = &state.config;
366 let start = Instant::now();
367
368 let project_path = Path::new(&req.path)
369 .canonicalize()
370 .unwrap_or_else(|_| Path::new(&req.path).to_path_buf());
371
372 let scan_result = walk_directory(&project_path, config).map_err(|e| {
374 (
375 StatusCode::INTERNAL_SERVER_ERROR,
376 Json(ErrorResponse {
377 error: "Scan failed".to_string(),
378 details: Some(e.to_string()),
379 }),
380 )
381 })?;
382
383 let entries: Vec<_> = scan_result
384 .entries
385 .iter()
386 .filter(|e| should_index_file(&e.path, e.size, config.max_file_size))
387 .collect();
388
389 let mut all_chunks: Vec<CodeChunk> = Vec::new();
391 let mut parsed_files = 0;
392
393 for entry in &entries {
394 match chunk_file_from_path(&entry.path) {
395 Ok(Some(parse_result)) => {
396 all_chunks.extend(parse_result.chunks);
397 parsed_files += 1;
398 }
399 Ok(None) => {}
400 Err(e) => {
401 tracing::debug!(path = %entry.path.display(), error = %e, "Failed to parse file");
402 }
403 }
404 }
405
406 if all_chunks.is_empty() {
407 return Ok(Json(IndexResponse {
408 status: "empty".to_string(),
409 project: project_path.display().to_string(),
410 chunks: 0,
411 files_parsed: 0,
412 embedding_dim: 0,
413 duration_ms: start.elapsed().as_millis(),
414 }));
415 }
416
417 let summaries: Vec<String> = all_chunks
419 .iter()
420 .map(|chunk| generate_summary(chunk))
421 .collect();
422
423 let embeddings = match create_embedder(config) {
425 Ok(embedder) => {
426 let batch = BatchEmbedder::new(embedder, config.embedding.batch_size);
427 batch.embed_all(&summaries).map_err(|e| {
428 (
429 StatusCode::INTERNAL_SERVER_ERROR,
430 Json(ErrorResponse {
431 error: "Embedding failed".to_string(),
432 details: Some(e.to_string()),
433 }),
434 )
435 })?
436 }
437 Err(_) => {
438 let dummy = DummyEmbedder::new(384);
439 let batch = BatchEmbedder::new(dummy, config.embedding.batch_size);
440 batch.embed_all(&summaries).map_err(|e| {
441 (
442 StatusCode::INTERNAL_SERVER_ERROR,
443 Json(ErrorResponse {
444 error: "Embedding failed".to_string(),
445 details: Some(e.to_string()),
446 }),
447 )
448 })?
449 }
450 };
451
452 let embedding_dim = embeddings.first().map(|e: &Vec<f32>| e.len()).unwrap_or(384);
453
454 let index = SeekrIndex::build_from(&all_chunks, &embeddings, embedding_dim);
456 let index_dir = config.project_index_dir(&project_path);
457 index.save(&index_dir).map_err(|e| {
458 (
459 StatusCode::INTERNAL_SERVER_ERROR,
460 Json(ErrorResponse {
461 error: "Index save failed".to_string(),
462 details: Some(e.to_string()),
463 }),
464 )
465 })?;
466
467 let elapsed = start.elapsed();
468
469 Ok(Json(IndexResponse {
470 status: "ok".to_string(),
471 project: project_path.display().to_string(),
472 chunks: all_chunks.len(),
473 files_parsed: parsed_files,
474 embedding_dim,
475 duration_ms: elapsed.as_millis(),
476 }))
477}
478
479async fn handle_status(
481 State(state): State<Arc<AppState>>,
482 axum::extract::Query(query): axum::extract::Query<StatusQuery>,
483) -> Json<StatusResponse> {
484 let config = &state.config;
485
486 let project_path = Path::new(&query.path)
487 .canonicalize()
488 .unwrap_or_else(|_| Path::new(&query.path).to_path_buf());
489
490 let index_dir = config.project_index_dir(&project_path);
491 let index_path = index_dir.join("index.json");
492
493 if !index_path.exists() {
494 return Json(StatusResponse {
495 indexed: false,
496 project: project_path.display().to_string(),
497 index_dir: index_dir.display().to_string(),
498 chunks: None,
499 embedding_dim: None,
500 version: None,
501 error: None,
502 message: Some("No index found. Run `seekr-code index` first.".to_string()),
503 });
504 }
505
506 match SeekrIndex::load(&index_dir) {
507 Ok(index) => Json(StatusResponse {
508 indexed: true,
509 project: project_path.display().to_string(),
510 index_dir: index_dir.display().to_string(),
511 chunks: Some(index.chunk_count),
512 embedding_dim: Some(index.embedding_dim),
513 version: Some(index.version),
514 error: None,
515 message: None,
516 }),
517 Err(e) => Json(StatusResponse {
518 indexed: true,
519 project: project_path.display().to_string(),
520 index_dir: index_dir.display().to_string(),
521 chunks: None,
522 embedding_dim: None,
523 version: None,
524 error: Some(e.to_string()),
525 message: None,
526 }),
527 }
528}
529
530fn create_embedder(config: &SeekrConfig) -> Result<Box<dyn Embedder>, String> {
532 match crate::embedder::onnx::OnnxEmbedder::new(&config.model_dir) {
533 Ok(embedder) => Ok(Box::new(embedder)),
534 Err(e) => {
535 tracing::warn!("ONNX embedder unavailable: {}, using dummy embedder", e);
536 Ok(Box::new(DummyEmbedder::new(384)))
537 }
538 }
539}