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::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
36/// Shared application state for HTTP handlers.
37pub struct AppState {
38    pub config: SeekrConfig,
39}
40
41// ============================================================
42// Request / Response types
43// ============================================================
44
45/// Request body for `POST /search`.
46#[derive(Debug, Deserialize)]
47pub struct SearchRequest {
48    /// Search query string.
49    pub query: String,
50
51    /// Search mode: "text", "semantic", "ast", or "hybrid".
52    #[serde(default = "default_mode")]
53    pub mode: String,
54
55    /// Maximum number of results.
56    #[serde(default = "default_top_k")]
57    pub top_k: usize,
58
59    /// Project path to search in.
60    #[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/// Request body for `POST /index`.
75#[derive(Debug, Deserialize)]
76pub struct IndexRequest {
77    /// Project path to index.
78    #[serde(default = "default_path")]
79    pub path: String,
80
81    /// Force full re-index.
82    #[serde(default)]
83    pub force: bool,
84}
85
86/// Response for `POST /index`.
87#[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/// Request params for `GET /status`.
98#[derive(Debug, Deserialize)]
99pub struct StatusQuery {
100    /// Project path to check (default: ".").
101    #[serde(default = "default_path")]
102    pub path: String,
103}
104
105/// Response for `GET /status`.
106#[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/// API error response.
124#[derive(Debug, Serialize)]
125pub struct ErrorResponse {
126    pub error: String,
127    pub details: Option<String>,
128}
129
130// ============================================================
131// Server startup
132// ============================================================
133
134/// Start the HTTP API server.
135pub 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
169// ============================================================
170// Handlers
171// ============================================================
172
173/// `GET /health` — Simple health check.
174async fn handle_health() -> Json<serde_json::Value> {
175    Json(serde_json::json!({
176        "status": "ok",
177        "version": crate::VERSION,
178    }))
179}
180
181/// `POST /search` — Execute a code search.
182async 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    // Parse search mode
190    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    // Resolve project path
201    let project_path = Path::new(&req.project_path)
202        .canonicalize()
203        .unwrap_or_else(|_| Path::new(&req.project_path).to_path_buf());
204
205    // Load index
206    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    // Execute search
222    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            // Try AST pattern search — silently degrade if query isn't a valid AST pattern
311            let ast_results = search_ast_pattern(&index, &req.query, top_k).unwrap_or_default();
312
313            if ast_results.is_empty() {
314                // 2-way fusion when no AST matches
315                rrf_fuse(&text_results, &semantic_results, config.search.rrf_k, top_k)
316            } else {
317                // 3-way fusion with AST
318                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    // Build response — propagate matched_lines from fusion results
344    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
373/// `POST /index` — Trigger index build for a project.
374async 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    // Step 1: Scan files
386    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    // Step 2: Parse & chunk
403    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    // Step 3: Generate summaries
431    let summaries: Vec<String> = all_chunks.iter().map(generate_summary).collect();
432
433    // Step 4: Generate embeddings
434    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    // Step 5: Build and save index
468    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
492/// `GET /status` — Query index status.
493async 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    // Check for v2 bincode index first, fall back to v1 JSON index
505    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
545/// Create an embedder instance. Falls back to DummyEmbedder if ONNX is unavailable.
546fn 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}