//! Search handler — full-text fuzzy search returning HTMX fragments. use axum::{ extract::Query, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use crate::templates::*; use crate::AppState; #[derive(Deserialize)] pub(super) struct SearchQuery { pub(super) q: Option, pub(super) scope: Option, } /// GET /search?q=...&scope=... — returns HTML fragment for HTMX swap. #[tracing::instrument(skip_all)] pub(super) async fn search_handler( axum::extract::State(state): axum::extract::State, Query(query): Query, ) -> Result { let q = query.q.as_deref().unwrap_or("").trim(); if q.is_empty() || q.len() < 2 { return Ok(SearchResultsFragment { results: vec![] }); } // Sanitize: limit length (find a char boundary to avoid UTF-8 panic) let q = if q.len() > 200 { let mut end = 200; while !q.is_char_boundary(end) { end -= 1; } &q[..end] } else { q }; let scope = query.scope.as_deref().filter(|s| !s.is_empty()); let db_results = mt_db::queries::search_threads(&state.db, q, scope, 20) .await .map_err(|e| { tracing::error!(error = ?e, "db error searching"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let results = db_results .into_iter() .map(|r| SearchResultViewRow { thread_id: r.thread_id.to_string(), thread_title: r.thread_title, author_username: r.author_username, community_name: r.community_name, community_slug: r.community_slug, category_name: r.category_name, category_slug: r.category_slug, snippet: r.snippet, last_activity: mt_core::time_format::relative_timestamp(r.last_activity_at), }) .collect(); Ok(SearchResultsFragment { results }) }