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, rrf_fuse_three};
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 let ast_results =
308 search_ast_pattern(&index, &req.query, top_k).unwrap_or_default();
309
310 if ast_results.is_empty() {
311 rrf_fuse(
313 &text_results,
314 &semantic_results,
315 config.search.rrf_k,
316 top_k,
317 )
318 } else {
319 rrf_fuse_three(
321 &text_results,
322 &semantic_results,
323 &ast_results,
324 config.search.rrf_k,
325 top_k,
326 )
327 }
328 }
329 SearchMode::Ast => {
330 let ast_results =
331 search_ast_pattern(&index, &req.query, top_k).map_err(|e| {
332 (
333 StatusCode::BAD_REQUEST,
334 Json(ErrorResponse {
335 error: "AST pattern search failed".to_string(),
336 details: Some(e.to_string()),
337 }),
338 )
339 })?;
340 fuse_ast_only(&ast_results, top_k)
341 }
342 };
343
344 let elapsed = start.elapsed();
345
346 let results: Vec<SearchResult> = fused_results
348 .iter()
349 .filter_map(|fused| {
350 index.get_chunk(fused.chunk_id).map(|chunk| SearchResult {
351 chunk: chunk.clone(),
352 score: fused.fused_score,
353 source: search_mode.clone(),
354 matched_lines: fused.matched_lines.clone(),
355 })
356 })
357 .collect();
358
359 let total = results.len();
360
361 let response = SearchResponse {
362 results,
363 total,
364 duration_ms: elapsed.as_millis() as u64,
365 query: SearchQuery {
366 query: req.query,
367 mode: search_mode,
368 top_k,
369 project_path: project_path.display().to_string(),
370 },
371 };
372
373 Ok(Json(response))
374}
375
376async fn handle_index(
378 State(state): State<Arc<AppState>>,
379 Json(req): Json<IndexRequest>,
380) -> Result<Json<IndexResponse>, (StatusCode, Json<ErrorResponse>)> {
381 let config = &state.config;
382 let start = Instant::now();
383
384 let project_path = Path::new(&req.path)
385 .canonicalize()
386 .unwrap_or_else(|_| Path::new(&req.path).to_path_buf());
387
388 let scan_result = walk_directory(&project_path, config).map_err(|e| {
390 (
391 StatusCode::INTERNAL_SERVER_ERROR,
392 Json(ErrorResponse {
393 error: "Scan failed".to_string(),
394 details: Some(e.to_string()),
395 }),
396 )
397 })?;
398
399 let entries: Vec<_> = scan_result
400 .entries
401 .iter()
402 .filter(|e| should_index_file(&e.path, e.size, config.max_file_size))
403 .collect();
404
405 let mut all_chunks: Vec<CodeChunk> = Vec::new();
407 let mut parsed_files = 0;
408
409 for entry in &entries {
410 match chunk_file_from_path(&entry.path) {
411 Ok(Some(parse_result)) => {
412 all_chunks.extend(parse_result.chunks);
413 parsed_files += 1;
414 }
415 Ok(None) => {}
416 Err(e) => {
417 tracing::debug!(path = %entry.path.display(), error = %e, "Failed to parse file");
418 }
419 }
420 }
421
422 if all_chunks.is_empty() {
423 return Ok(Json(IndexResponse {
424 status: "empty".to_string(),
425 project: project_path.display().to_string(),
426 chunks: 0,
427 files_parsed: 0,
428 embedding_dim: 0,
429 duration_ms: start.elapsed().as_millis(),
430 }));
431 }
432
433 let summaries: Vec<String> = all_chunks
435 .iter()
436 .map(|chunk| generate_summary(chunk))
437 .collect();
438
439 let embeddings = match create_embedder(config) {
441 Ok(embedder) => {
442 let batch = BatchEmbedder::new(embedder, config.embedding.batch_size);
443 batch.embed_all(&summaries).map_err(|e| {
444 (
445 StatusCode::INTERNAL_SERVER_ERROR,
446 Json(ErrorResponse {
447 error: "Embedding failed".to_string(),
448 details: Some(e.to_string()),
449 }),
450 )
451 })?
452 }
453 Err(_) => {
454 let dummy = DummyEmbedder::new(384);
455 let batch = BatchEmbedder::new(dummy, config.embedding.batch_size);
456 batch.embed_all(&summaries).map_err(|e| {
457 (
458 StatusCode::INTERNAL_SERVER_ERROR,
459 Json(ErrorResponse {
460 error: "Embedding failed".to_string(),
461 details: Some(e.to_string()),
462 }),
463 )
464 })?
465 }
466 };
467
468 let embedding_dim = embeddings.first().map(|e: &Vec<f32>| e.len()).unwrap_or(384);
469
470 let index = SeekrIndex::build_from(&all_chunks, &embeddings, embedding_dim);
472 let index_dir = config.project_index_dir(&project_path);
473 index.save(&index_dir).map_err(|e| {
474 (
475 StatusCode::INTERNAL_SERVER_ERROR,
476 Json(ErrorResponse {
477 error: "Index save failed".to_string(),
478 details: Some(e.to_string()),
479 }),
480 )
481 })?;
482
483 let elapsed = start.elapsed();
484
485 Ok(Json(IndexResponse {
486 status: "ok".to_string(),
487 project: project_path.display().to_string(),
488 chunks: all_chunks.len(),
489 files_parsed: parsed_files,
490 embedding_dim,
491 duration_ms: elapsed.as_millis(),
492 }))
493}
494
495async fn handle_status(
497 State(state): State<Arc<AppState>>,
498 axum::extract::Query(query): axum::extract::Query<StatusQuery>,
499) -> Json<StatusResponse> {
500 let config = &state.config;
501
502 let project_path = Path::new(&query.path)
503 .canonicalize()
504 .unwrap_or_else(|_| Path::new(&query.path).to_path_buf());
505
506 let index_dir = config.project_index_dir(&project_path);
507 let index_exists = index_dir.join("index.bin").exists() || index_dir.join("index.json").exists();
509
510 if !index_exists {
511 return Json(StatusResponse {
512 indexed: false,
513 project: project_path.display().to_string(),
514 index_dir: index_dir.display().to_string(),
515 chunks: None,
516 embedding_dim: None,
517 version: None,
518 error: None,
519 message: Some("No index found. Run `seekr-code index` first.".to_string()),
520 });
521 }
522
523 match SeekrIndex::load(&index_dir) {
524 Ok(index) => Json(StatusResponse {
525 indexed: true,
526 project: project_path.display().to_string(),
527 index_dir: index_dir.display().to_string(),
528 chunks: Some(index.chunk_count),
529 embedding_dim: Some(index.embedding_dim),
530 version: Some(index.version),
531 error: None,
532 message: None,
533 }),
534 Err(e) => Json(StatusResponse {
535 indexed: true,
536 project: project_path.display().to_string(),
537 index_dir: index_dir.display().to_string(),
538 chunks: None,
539 embedding_dim: None,
540 version: None,
541 error: Some(e.to_string()),
542 message: None,
543 }),
544 }
545}
546
547fn create_embedder(config: &SeekrConfig) -> Result<Box<dyn Embedder>, String> {
549 match crate::embedder::onnx::OnnxEmbedder::new(&config.model_dir) {
550 Ok(embedder) => Ok(Box::new(embedder)),
551 Err(e) => {
552 tracing::warn!("ONNX embedder unavailable: {}, using dummy embedder", e);
553 Ok(Box::new(DummyEmbedder::new(384)))
554 }
555 }
556}