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};
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            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    // Build response — propagate matched_lines from fusion results
331    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
360/// `POST /index` — Trigger index build for a project.
361async 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    // Step 1: Scan files
373    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    // Step 2: Parse & chunk
390    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    // Step 3: Generate summaries
418    let summaries: Vec<String> = all_chunks
419        .iter()
420        .map(|chunk| generate_summary(chunk))
421        .collect();
422
423    // Step 4: Generate embeddings
424    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    // Step 5: Build and save index
455    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
479/// `GET /status` — Query index status.
480async 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
530/// Create an embedder instance. Falls back to DummyEmbedder if ONNX is unavailable.
531fn 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}