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