/// Query Handler Helpers /// /// Extracted to reduce query_handler complexity. use actix_web::HttpResponse; use serde::Serialize; use serde_json::json; use std::collections::HashMap; use crate::query_worker::QueryResult; // ============================================================================ // Query Parameters // ============================================================================ /// Search method for query endpoint #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SearchMethod { Semantic, #[default] Hybrid, } impl SearchMethod { pub fn from_str(s: &str) -> Self { match s { "semantic" => Self::Semantic, _ => Self::Hybrid, } } pub fn as_str(&self) -> &'static str { match self { Self::Semantic => "semantic", Self::Hybrid => "hybrid", } } } /// Validated query parameters #[derive(Debug, Clone)] pub struct QueryParams { pub project: String, pub question: String, pub limit: i64, pub method: SearchMethod, } impl QueryParams { /// Parse and validate query parameters pub fn from_query(query: &HashMap) -> Result { let project = query.get("project") .filter(|p| !p.is_empty()) .ok_or(QueryParamsError::MissingProject)? .clone(); let question = query.get("query") .filter(|q| !q.is_empty()) .ok_or(QueryParamsError::MissingQuery)? .clone(); let limit = query.get("limit") .and_then(|l| l.parse::().ok()) .unwrap_or(10) .clamp(1, 100); let method = query.get("method") .map(|m| SearchMethod::from_str(m)) .unwrap_or_default(); Ok(Self { project, question, limit, method }) } } /// Query parameter validation errors #[derive(Debug, Clone, PartialEq, Eq)] pub enum QueryParamsError { MissingProject, MissingQuery, } impl QueryParamsError { pub fn to_response(&self) -> HttpResponse { let reason = match self { Self::MissingProject => "missing project parameter", Self::MissingQuery => "missing query parameter", }; HttpResponse::BadRequest().json(json!({"error": reason})) } } // ============================================================================ // Search Response Builder // ============================================================================ #[derive(Debug, Clone, Serialize)] pub struct SearchResultItem { pub level: String, pub score: f32, pub text: String, pub source: Option, pub provenance: Vec, } impl From for SearchResultItem { fn from(r: QueryResult) -> Self { Self { level: r.level, score: r.score, text: r.text, source: r.source, provenance: r.provenance, } } } /// Build search response JSON pub fn build_search_response( params: &QueryParams, results: Vec, method_override: Option<&str>, ) -> HttpResponse { let items: Vec = results .into_iter() .take(params.limit as usize) .map(SearchResultItem::from) .collect(); HttpResponse::Ok().json(json!({ "query": params.question, "project": params.project, "method": method_override.unwrap_or(params.method.as_str()), "results": items })) } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; fn make_query(pairs: &[(&str, &str)]) -> HashMap { pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() } #[test] fn test_query_params_valid() { let query = make_query(&[("project", "homelab"), ("query", "kubernetes")]); let params = QueryParams::from_query(&query).unwrap(); assert_eq!(params.project, "homelab"); assert_eq!(params.question, "kubernetes"); assert_eq!(params.limit, 10); assert_eq!(params.method, SearchMethod::Hybrid); } #[test] fn test_query_params_with_limit() { let query = make_query(&[("project", "test"), ("query", "foo"), ("limit", "25")]); let params = QueryParams::from_query(&query).unwrap(); assert_eq!(params.limit, 25); } #[test] fn test_query_params_limit_clamped_max() { let query = make_query(&[("project", "test"), ("query", "foo"), ("limit", "999")]); let params = QueryParams::from_query(&query).unwrap(); assert_eq!(params.limit, 100); } #[test] fn test_query_params_limit_clamped_min() { let query = make_query(&[("project", "test"), ("query", "foo"), ("limit", "0")]); let params = QueryParams::from_query(&query).unwrap(); assert_eq!(params.limit, 1); } #[test] fn test_query_params_semantic_method() { let query = make_query(&[("project", "test"), ("query", "foo"), ("method", "semantic")]); let params = QueryParams::from_query(&query).unwrap(); assert_eq!(params.method, SearchMethod::Semantic); } #[test] fn test_query_params_missing_project() { let query = make_query(&[("query", "foo")]); assert_eq!(QueryParams::from_query(&query).unwrap_err(), QueryParamsError::MissingProject); } #[test] fn test_query_params_empty_project() { let query = make_query(&[("project", ""), ("query", "foo")]); assert_eq!(QueryParams::from_query(&query).unwrap_err(), QueryParamsError::MissingProject); } #[test] fn test_query_params_missing_query() { let query = make_query(&[("project", "homelab")]); assert_eq!(QueryParams::from_query(&query).unwrap_err(), QueryParamsError::MissingQuery); } #[test] fn test_query_params_empty_query() { let query = make_query(&[("project", "homelab"), ("query", "")]); assert_eq!(QueryParams::from_query(&query).unwrap_err(), QueryParamsError::MissingQuery); } #[test] fn test_search_method_from_str() { assert_eq!(SearchMethod::from_str("semantic"), SearchMethod::Semantic); assert_eq!(SearchMethod::from_str("hybrid"), SearchMethod::Hybrid); assert_eq!(SearchMethod::from_str("unknown"), SearchMethod::Hybrid); } }