Skip to main content

seekr_code/server/
http.rs

1//! HTTP API server.
2//!
3//! REST API built with axum, bound to 127.0.0.1 (configurable port):
4//! - `POST /search` — Search code with various modes
5//! - `POST /index`  — Trigger index build for a project
6//! - `GET  /status` — Query index status for a project
7
8use 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
34/// Shared application state for HTTP handlers.
35pub struct AppState {
36    pub config: SeekrConfig,
37}
38
39// ============================================================
40// Request / Response types
41// ============================================================
42
43/// Request body for `POST /search`.
44#[derive(Debug, Deserialize)]
45pub struct SearchRequest {
46    /// Search query string.
47    pub query: String,
48
49    /// Search mode: "text", "semantic", "ast", or "hybrid".
50    #[serde(default = "default_mode")]
51    pub mode: String,
52
53    /// Maximum number of results.
54    #[serde(default = "default_top_k")]
55    pub top_k: usize,
56
57    /// Project path to search in.
58    #[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/// Request body for `POST /index`.
73#[derive(Debug, Deserialize)]
74pub struct IndexRequest {
75    /// Project path to index.
76    #[serde(default = "default_path")]
77    pub path: String,
78
79    /// Force full re-index.
80    #[serde(default)]
81    pub force: bool,
82}
83
84/// Response for `POST /index`.
85#[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/// Request params for `GET /status`.
96#[derive(Debug, Deserialize)]
97pub struct StatusQuery {
98    /// Project path to check (default: ".").
99    #[serde(default = "default_path")]
100    pub path: String,
101}
102
103/// Response for `GET /status`.
104#[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/// API error response.
122#[derive(Debug, Serialize)]
123pub struct ErrorResponse {
124    pub error: String,
125    pub details: Option<String>,
126}
127
128// ============================================================
129// Server startup
130// ============================================================
131
132/// Start the HTTP API server.
133pub 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
166// ============================================================
167// Handlers
168// ============================================================
169
170/// `GET /health` — Simple health check.
171async fn handle_health() -> Json<serde_json::Value> {
172    Json(serde_json::json!({
173        "status": "ok",
174        "version": crate::VERSION,
175    }))
176}
177
178/// `POST /search` — Execute a code search.
179async 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    // Parse search mode
187    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    // Resolve project path
198    let project_path = Path::new(&req.project_path)
199        .canonicalize()
200        .unwrap_or_else(|_| Path::new(&req.project_path).to_path_buf());
201
202    // Load index
203    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    // Execute search
219    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            // Try AST pattern search — silently degrade if query isn't a valid AST pattern
307            let ast_results =
308                search_ast_pattern(&index, &req.query, top_k).unwrap_or_default();
309
310            if ast_results.is_empty() {
311                // 2-way fusion when no AST matches
312                rrf_fuse(
313                    &text_results,
314                    &semantic_results,
315                    config.search.rrf_k,
316                    top_k,
317                )
318            } else {
319                // 3-way fusion with AST
320                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    // Build response — propagate matched_lines from fusion results
347    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
376/// `POST /index` — Trigger index build for a project.
377async 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    // Step 1: Scan files
389    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    // Step 2: Parse & chunk
406    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    // Step 3: Generate summaries
434    let summaries: Vec<String> = all_chunks
435        .iter()
436        .map(|chunk| generate_summary(chunk))
437        .collect();
438
439    // Step 4: Generate embeddings
440    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    // Step 5: Build and save index
471    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
495/// `GET /status` — Query index status.
496async 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    // Check for v2 bincode index first, fall back to v1 JSON index
508    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
547/// Create an embedder instance. Falls back to DummyEmbedder if ONNX is unavailable.
548fn 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}