use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use snafu::Snafu;
#[derive(Debug, Snafu)]
pub enum SearchError {
#[snafu(display("Invalid search request: {message}"))]
InvalidRequest { message: String },
#[snafu(display("Search failed (HTTP {status_code}): {response_body}"))]
SearchFailed {
status_code: u16,
response_body: String,
},
#[snafu(display("Search failed: {message}"))]
HttpError { message: String },
#[snafu(display("Failed to parse search response: {message}"))]
ParseError { message: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SearchRequest {
pub text: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub datasets: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(rename = "where", skip_serializing_if = "Option::is_none")]
pub where_cond: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub additional_columns: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
}
impl SearchRequest {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Default::default()
}
}
#[must_use]
pub fn with_datasets<I, S>(mut self, datasets: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.datasets = datasets.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn with_where(mut self, where_cond: impl Into<String>) -> Self {
self.where_cond = Some(where_cond.into());
self
}
#[must_use]
pub fn with_additional_columns<I, S>(mut self, columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.additional_columns = columns.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_keywords<I, S>(mut self, keywords: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.keywords = keywords.into_iter().map(Into::into).collect();
self
}
pub(crate) fn validate(&self) -> Result<(), SearchError> {
if self.text.trim().is_empty() {
return Err(SearchError::InvalidRequest {
message: "text is required and must be a non-empty search string".to_string(),
});
}
if self.limit == Some(0) {
return Err(SearchError::InvalidRequest {
message: "limit must be greater than 0".to_string(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct SearchMatch {
#[serde(default)]
pub dataset: String,
#[serde(rename = "_score", default)]
pub score: f64,
#[serde(default)]
pub matches: HashMap<String, Vec<serde_json::Value>>,
#[serde(default)]
pub primary_key: HashMap<String, serde_json::Value>,
#[serde(default)]
pub data: HashMap<String, serde_json::Value>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct SearchResponse {
#[serde(default)]
pub results: Vec<SearchMatch>,
#[serde(default)]
pub duration_ms: u128,
}
impl SearchResponse {
#[must_use]
pub fn len(&self) -> usize {
self.results.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.results.is_empty()
}
}
impl IntoIterator for SearchResponse {
type Item = SearchMatch;
type IntoIter = std::vec::IntoIter<SearchMatch>;
fn into_iter(self) -> Self::IntoIter {
self.results.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn body(request: &SearchRequest) -> serde_json::Value {
serde_json::to_value(request).expect("serialize search request")
}
#[test]
fn test_text_only_request_omits_optional_fields() {
assert_eq!(
body(&SearchRequest::new("tokyo")),
serde_json::json!({"text": "tokyo"})
);
}
#[test]
fn test_full_request_serialization() {
let request = SearchRequest::new("tokyo")
.with_datasets(["app_messages"])
.with_limit(3)
.with_where("user_id = 42")
.with_additional_columns(["timestamp"])
.with_keywords(["plane", "tickets"]);
assert_eq!(
body(&request),
serde_json::json!({
"text": "tokyo",
"datasets": ["app_messages"],
"limit": 3,
"where": "user_id = 42",
"additional_columns": ["timestamp"],
"keywords": ["plane", "tickets"],
})
);
}
#[test]
fn test_empty_datasets_omitted() {
let request = SearchRequest::new("tokyo").with_datasets(Vec::<String>::new());
assert_eq!(body(&request), serde_json::json!({"text": "tokyo"}));
}
#[test]
fn test_validate_rejects_empty_text() {
let err = SearchRequest::new(" ")
.validate()
.expect_err("empty text should be rejected");
assert!(err.to_string().contains("non-empty"), "{err}");
}
#[test]
fn test_validate_rejects_zero_limit() {
let err = SearchRequest::new("tokyo")
.with_limit(0)
.validate()
.expect_err("zero limit should be rejected");
assert!(err.to_string().contains("greater than 0"), "{err}");
}
#[test]
fn test_validate_accepts_minimal_request() {
SearchRequest::new("tokyo").validate().expect("valid");
}
#[test]
fn test_response_deserialization() {
let response: SearchResponse = serde_json::from_str(
r#"{
"results": [
{
"matches": {"message": ["I booked us some tickets", "direct to Narita"]},
"dataset": "app_messages",
"primary_key": {"id": "6fd5a215"},
"data": {"timestamp": 1724716542},
"metadata": {"chunk": 2},
"_score": 0.914321
},
{
"matches": {"message": ["we're sitting together"]},
"dataset": "app_messages",
"_score": 0.787654
}
],
"duration_ms": 42
}"#,
)
.expect("deserialize search response");
assert_eq!(response.duration_ms, 42);
assert_eq!(response.len(), 2);
assert!(!response.is_empty());
let first = &response.results[0];
assert_eq!(first.dataset, "app_messages");
assert!((first.score - 0.914_321).abs() < f64::EPSILON);
assert_eq!(first.matches["message"].len(), 2);
assert_eq!(first.primary_key["id"], "6fd5a215");
assert_eq!(first.data["timestamp"], 1_724_716_542_i64);
assert_eq!(first.metadata["chunk"], 2);
let second = &response.results[1];
assert!(second.primary_key.is_empty());
assert!(second.data.is_empty());
assert!(second.metadata.is_empty());
}
#[test]
fn test_empty_response() {
let response: SearchResponse =
serde_json::from_str(r#"{"results": [], "duration_ms": 3}"#).expect("deserialize");
assert!(response.is_empty());
assert_eq!(response.into_iter().count(), 0);
}
}