use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use crate::memory::service;
use super::super::helpers::{
error_response, memories_to_items_with_conn, open_request_db, staleness_error_response,
};
use super::super::types::{
DbState, MemoryItem, Meta, MultiHopInfo, RawHitItem, SearchParams, SearchResponse,
};
pub(in crate::api) fn search_request_from_params(params: SearchParams) -> service::SearchRequest {
let limit = params.limit.unwrap_or(20).min(100);
let offset = params.offset.unwrap_or(0).max(0);
service::SearchRequest {
query: params.query,
project: params.project,
memory_type: params.memory_type,
limit,
offset,
include_stale: params
.include_stale
.unwrap_or_else(service::default_include_stale),
include_suppressed: params
.include_suppressed
.unwrap_or_else(service::default_include_suppressed),
branch: params.branch,
multi_hop: params.multi_hop.unwrap_or(false),
explain: params.explain.unwrap_or(false),
}
}
pub(in crate::api) async fn handle_search(
State(_state): State<DbState>,
Query(params): Query<SearchParams>,
) -> impl IntoResponse {
if params.explain.unwrap_or(false)
&& params
.query
.as_deref()
.is_none_or(|query| query.trim().is_empty())
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_search_request",
"explain requires a non-empty query; set query or explain=false",
)
.into_response();
}
if params.multi_hop.unwrap_or(false) && params.explain.unwrap_or(false) {
return error_response(
StatusCode::BAD_REQUEST,
"invalid_search_request",
"explain is not supported with multi_hop search yet; set multi_hop=false or explain=false",
)
.into_response();
}
let conn = match open_request_db() {
Ok(conn) => conn,
Err(response) => return response,
};
let req = search_request_from_params(params);
let limit = req.limit;
let offset = req.offset;
const RAW_PREVIEW_CHARS: usize = 300;
match service::search_memories(&conn, &req) {
Ok(results) => {
let count = results.memories.len();
let items: Vec<MemoryItem> = match memories_to_items_with_conn(&conn, &results.memories)
{
Ok(items) => items,
Err(err) => return staleness_error_response(&err).into_response(),
};
let raw_hits: Vec<RawHitItem> = results
.raw_hits
.into_iter()
.map(|msg| RawHitItem {
id: msg.id,
session_id: msg.session_id,
project: msg.project,
role: msg.role,
preview: msg.content.chars().take(RAW_PREVIEW_CHARS).collect(),
source: msg.source,
branch: msg.branch,
created_at_epoch: msg.created_at_epoch,
})
.collect();
Json(SearchResponse {
data: items,
meta: Meta {
count,
has_more: results.has_more,
limit,
offset,
},
multi_hop: results.multi_hop.map(|meta| MultiHopInfo {
hops: meta.hops,
entities_discovered: meta.entities_discovered,
}),
raw_hits,
raw_hits_error: results.raw_error,
explain: results.explain,
})
.into_response()
}
Err(err) if err.to_string().contains("source-anchor staleness") => {
staleness_error_response(&err).into_response()
}
Err(err) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"search_failed",
&err.to_string(),
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use axum::{
body::to_bytes,
extract::{Query, State},
response::IntoResponse,
};
use serde_json::Value;
use super::*;
use crate::db::test_support::ScopedTestDataDir;
use crate::memory;
fn base_search_params(explain: Option<bool>) -> SearchParams {
SearchParams {
query: Some("aurora".to_string()),
project: Some("/repo".to_string()),
memory_type: None,
limit: Some(5),
offset: Some(0),
include_stale: Some(true),
include_suppressed: None,
branch: None,
multi_hop: Some(false),
explain,
}
}
fn multi_hop_explain_params() -> SearchParams {
SearchParams {
multi_hop: Some(true),
explain: Some(true),
..base_search_params(None)
}
}
#[test]
fn search_request_from_params_keeps_explain_default_false() {
let request = search_request_from_params(base_search_params(None));
assert!(!request.explain);
}
#[test]
fn search_request_from_params_passes_explain_true() {
let request = search_request_from_params(base_search_params(Some(true)));
assert!(request.explain);
}
#[tokio::test]
async fn handle_search_emits_explain_only_when_requested() -> Result<()> {
let _dir = ScopedTestDataDir::new("api-search-explain");
let conn = crate::db::open_db()?;
let memory_id = memory::insert_memory(
&conn,
Some("session-1"),
"/repo",
Some("aurora-contract"),
"Aurora contract decision",
"The aurora recall contract keeps search compact before expansion.",
"decision",
None,
)?;
drop(conn);
let default_response = handle_search(State(DbState), Query(base_search_params(None)))
.await
.into_response();
let default_body = to_bytes(default_response.into_body(), usize::MAX).await?;
let default_json: Value = serde_json::from_slice(&default_body)?;
assert!(default_json.get("explain").is_none());
assert_eq!(default_json["data"][0]["id"], memory_id);
assert_eq!(default_json["data"][0]["staleness"]["status"], "active");
assert_eq!(
default_json["data"][0]["staleness"]["source_anchor"],
"untracked"
);
let explain_response = handle_search(State(DbState), Query(base_search_params(Some(true))))
.await
.into_response();
let explain_body = to_bytes(explain_response.into_body(), usize::MAX).await?;
let explain_json: Value = serde_json::from_slice(&explain_body)?;
assert_eq!(explain_json["data"][0]["id"], memory_id);
assert_eq!(explain_json["data"][0]["staleness"]["status"], "active");
assert_eq!(
explain_json["data"][0]["staleness"]["source_anchor"],
"untracked"
);
assert_eq!(explain_json["explain"]["query"], "aurora");
assert_eq!(
explain_json["explain"]["results"][0]["memory_id"],
memory_id
);
assert_eq!(
explain_json["explain"]["results"][0]["staleness"]["status"],
"active"
);
Ok(())
}
#[tokio::test]
async fn handle_search_exposes_raw_fallback_failure() -> Result<()> {
let _dir = ScopedTestDataDir::new("api-search-raw-fallback-error");
let conn = crate::db::open_db()?;
conn.execute("DROP TABLE raw_messages_fts", [])?;
drop(conn);
let response = handle_search(State(DbState), Query(base_search_params(None)))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await?;
let json: Value = serde_json::from_slice(&body)?;
assert_eq!(json["data"].as_array().map(Vec::len), Some(0));
assert_eq!(json.get("raw_hits"), None);
assert!(json["raw_hits_error"]
.as_str()
.is_some_and(|error| error.contains("raw archive fallback failed")));
Ok(())
}
#[tokio::test]
async fn handle_search_fails_when_source_anchor_label_fails() -> Result<()> {
let _dir = ScopedTestDataDir::new("api-search-staleness-source-error");
let conn = crate::db::open_db()?;
let memory_id = memory::insert_memory(
&conn,
Some("session-search-bad-staleness"),
"/repo",
None,
"aurora bad staleness",
"aurora bad source-anchor fixture",
"decision",
None,
)?;
conn.execute(
"UPDATE memories SET files = '[not-json' WHERE id = ?1",
[memory_id],
)?;
drop(conn);
let response = handle_search(State(DbState), Query(base_search_params(None)))
.await
.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = to_bytes(response.into_body(), usize::MAX).await?;
let json: Value = serde_json::from_slice(&body)?;
assert_eq!(json["error"]["code"], "staleness_source_anchor_failed");
let explain_response = handle_search(State(DbState), Query(base_search_params(Some(true))))
.await
.into_response();
assert_eq!(explain_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let explain_body = to_bytes(explain_response.into_body(), usize::MAX).await?;
let explain_json: Value = serde_json::from_slice(&explain_body)?;
assert_eq!(
explain_json["error"]["code"],
"staleness_source_anchor_failed"
);
Ok(())
}
#[tokio::test]
async fn handle_search_rejects_multi_hop_explain() -> Result<()> {
let _dir = ScopedTestDataDir::new("api-search-explain-multi-hop");
let response = handle_search(State(DbState), Query(multi_hop_explain_params()))
.await
.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await?;
let json: Value = serde_json::from_slice(&body)?;
assert_eq!(json["error"]["code"], "invalid_search_request");
assert!(
json["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("multi_hop"),
"{}",
json
);
Ok(())
}
#[tokio::test]
async fn handle_search_rejects_explain_without_query() -> Result<()> {
for query in [None, Some("")] {
let mut params = base_search_params(Some(true));
params.query = query.map(str::to_string);
let response = handle_search(State(DbState), Query(params))
.await
.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await?;
let json: Value = serde_json::from_slice(&body)?;
assert_eq!(json["error"]["code"], "invalid_search_request");
assert!(
json["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("non-empty query"),
"{}",
json
);
}
Ok(())
}
}