use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum QueryMode {
Fuzzy,
Exact,
Dynamic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OccurMode {
#[default]
Should,
Must,
#[serde(rename = "mustnot")]
MustNot,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FieldSelector {
Single(String),
Multi(Vec<String>),
MultiWithBoost(std::collections::HashMap<String, f32>),
}
impl From<&str> for FieldSelector {
fn from(field: &str) -> Self {
Self::Single(field.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueryContext {
pub ctx: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<FieldSelector>,
}
impl QueryContext {
#[must_use]
pub fn new(ctx: impl Into<String>) -> Self {
Self {
ctx: ctx.into(),
fields: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MoreLikeThisContext {
pub ctx: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub occur: Option<OccurMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fuzzy: Option<QueryContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exact: Option<QueryContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dynamic: Option<QueryContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub term: Option<QueryContext>,
#[serde(rename = "more-like-this", skip_serializing_if = "Option::is_none")]
pub more_like_this: Option<MoreLikeThisContext>,
}
impl SearchQuery {
fn empty() -> Self {
Self {
occur: None,
fuzzy: None,
exact: None,
dynamic: None,
term: None,
more_like_this: None,
}
}
#[must_use]
pub fn fuzzy(ctx: impl Into<String>) -> Self {
Self {
fuzzy: Some(QueryContext::new(ctx)),
..Self::empty()
}
}
#[must_use]
pub fn exact(ctx: impl Into<String>) -> Self {
Self {
exact: Some(QueryContext::new(ctx)),
..Self::empty()
}
}
#[must_use]
pub fn dynamic(ctx: impl Into<String>) -> Self {
Self {
dynamic: Some(QueryContext::new(ctx)),
..Self::empty()
}
}
#[must_use]
pub fn term(ctx: impl Into<String>, field: impl Into<String>) -> Self {
Self {
term: Some(QueryContext {
ctx: ctx.into(),
fields: Some(FieldSelector::Single(field.into())),
}),
..Self::empty()
}
}
#[must_use]
pub fn more_like_this(document_id: impl Into<String>) -> Self {
Self {
more_like_this: Some(MoreLikeThisContext {
ctx: document_id.into(),
}),
..Self::empty()
}
}
#[must_use]
pub fn with_fields(mut self, fields: FieldSelector) -> Self {
if let Some(ctx) = self.fuzzy.as_mut().or(self.term.as_mut()) {
ctx.fields = Some(fields);
}
self
}
#[must_use]
pub fn with_occur(mut self, occur: OccurMode) -> Self {
self.occur = Some(occur);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum QueryPayload {
Single(SearchQuery),
Multiple(Vec<SearchQuery>),
}
impl From<SearchQuery> for QueryPayload {
fn from(q: SearchQuery) -> Self {
Self::Single(q)
}
}
impl From<Vec<SearchQuery>> for QueryPayload {
fn from(qs: Vec<SearchQuery>) -> Self {
Self::Multiple(qs)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchRequest {
pub query: QueryPayload,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<crate::types::SortDirection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_decay_field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub index_weighting: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum_number_should_match: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchHit<T = serde_json::Value> {
pub doc: T,
pub document_id: String,
#[serde(default)]
pub score: Option<f64>,
#[serde(default)]
pub source_index: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FacetPath {
pub path: String,
pub count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<FacetPath>>,
}
pub type Facet = std::collections::HashMap<String, Vec<FacetPath>>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponseData<T = serde_json::Value> {
pub hits: Vec<SearchHit<T>>,
pub count: u64,
pub time_taken: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub facets: Option<Vec<Facet>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "lowercase")]
pub enum SummaryStreamEvent {
Metadata(SummaryMetadata),
Delta(SummaryDelta),
Done(SummaryDone),
Error(SummaryError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryMetadata {
pub results_count: u64,
pub cached: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryDelta {
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryDone {
pub results_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryError {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse<T = serde_json::Value> {
pub status: u16,
pub data: SearchResponseData<T>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuzzy_query_serialization() {
let q = SearchQuery::fuzzy("laptop");
let json = serde_json::to_value(&q).unwrap();
assert_eq!(json, serde_json::json!({"fuzzy": {"ctx": "laptop"}}));
}
#[test]
fn exact_query_serialization() {
let q = SearchQuery::exact("laptop");
let json = serde_json::to_value(&q).unwrap();
assert_eq!(json, serde_json::json!({"exact": {"ctx": "laptop"}}));
}
#[test]
fn dynamic_query_serialization() {
let q = SearchQuery::dynamic("laptop");
let json = serde_json::to_value(&q).unwrap();
assert_eq!(json, serde_json::json!({"dynamic": {"ctx": "laptop"}}));
}
#[test]
fn query_with_occur_serialization() {
let q = SearchQuery::fuzzy("laptop").with_occur(OccurMode::Must);
let json = serde_json::to_value(&q).unwrap();
assert_eq!(
json,
serde_json::json!({"occur": "must", "fuzzy": {"ctx": "laptop"}})
);
}
#[test]
fn search_request_serialization() {
let req = SearchRequest {
query: QueryPayload::Single(SearchQuery::fuzzy("laptop")),
limit: Some(10),
offset: Some(0),
order_by: None,
sort: None,
time_decay_field: None,
index_weighting: None,
minimum_number_should_match: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["query"]["fuzzy"]["ctx"], "laptop");
assert_eq!(json["limit"], 10);
assert_eq!(json["offset"], 0);
assert!(json.get("order_by").is_none());
}
#[test]
fn search_request_with_sorting() {
let req = SearchRequest {
query: QueryPayload::Single(SearchQuery::exact("test")),
limit: Some(20),
offset: None,
order_by: Some("price".into()),
sort: Some(crate::types::SortDirection::Asc),
time_decay_field: None,
index_weighting: None,
minimum_number_should_match: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["order_by"], "price");
assert_eq!(json["sort"], "asc");
}
#[test]
fn multiple_queries_serialization() {
let queries = vec![
SearchQuery::fuzzy("laptop").with_occur(OccurMode::Must),
SearchQuery::exact("gaming").with_occur(OccurMode::Should),
];
let req = SearchRequest {
query: QueryPayload::Multiple(queries),
limit: Some(10),
offset: None,
order_by: None,
sort: None,
time_decay_field: None,
index_weighting: None,
minimum_number_should_match: None,
};
let json = serde_json::to_value(&req).unwrap();
let query_arr = json["query"].as_array().unwrap();
assert_eq!(query_arr.len(), 2);
assert_eq!(query_arr[0]["occur"], "must");
assert_eq!(query_arr[1]["occur"], "should");
}
#[test]
fn search_response_deserialization() {
let json = serde_json::json!({
"status": 200,
"data": {
"hits": [
{
"doc": {"title": "Gaming Laptop", "price": 999},
"document_id": "doc-1",
"score": 0.95,
"source_index": "products"
}
],
"count": 1,
"time_taken": 12.5
}
});
let resp: SearchResponse = serde_json::from_value(json).unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.data.count, 1);
assert_eq!(resp.data.hits.len(), 1);
assert_eq!(resp.data.hits[0].document_id, "doc-1");
assert!((resp.data.hits[0].score.unwrap() - 0.95).abs() < f64::EPSILON);
assert_eq!(resp.data.hits[0].source_index, "products");
}
#[test]
fn search_query_roundtrip() {
let q = SearchQuery::fuzzy("test query");
let json = serde_json::to_string(&q).unwrap();
let parsed: SearchQuery = serde_json::from_str(&json).unwrap();
assert_eq!(q, parsed);
}
#[test]
fn term_and_more_like_this_queries_serialize() {
let term = SearchQuery::term("electronics", "category").with_occur(OccurMode::Must);
assert_eq!(
serde_json::to_value(&term).unwrap(),
serde_json::json!({
"occur": "must",
"term": { "ctx": "electronics", "fields": "category" }
})
);
let mlt = SearchQuery::more_like_this("12345");
assert_eq!(
serde_json::to_value(&mlt).unwrap(),
serde_json::json!({ "more-like-this": { "ctx": "12345" } })
);
}
#[test]
fn mustnot_occur_serializes_without_a_separator() {
let q = SearchQuery::exact("draft").with_occur(OccurMode::MustNot);
assert_eq!(
serde_json::to_value(&q).unwrap(),
serde_json::json!({ "occur": "mustnot", "exact": { "ctx": "draft" } })
);
}
#[test]
fn fuzzy_query_can_target_specific_fields() {
let single =
SearchQuery::fuzzy("laptop").with_fields(FieldSelector::Single("title".into()));
assert_eq!(
serde_json::to_value(&single).unwrap(),
serde_json::json!({ "fuzzy": { "ctx": "laptop", "fields": "title" } })
);
let multi = SearchQuery::fuzzy("laptop")
.with_fields(FieldSelector::Multi(vec!["title".into(), "body".into()]));
assert_eq!(
serde_json::to_value(&multi).unwrap(),
serde_json::json!({ "fuzzy": { "ctx": "laptop", "fields": ["title", "body"] } })
);
let boosted = SearchQuery::fuzzy("laptop").with_fields(FieldSelector::MultiWithBoost(
std::collections::HashMap::from([("title".to_string(), 2.0)]),
));
assert_eq!(
serde_json::to_value(&boosted).unwrap(),
serde_json::json!({ "fuzzy": { "ctx": "laptop", "fields": { "title": 2.0 } } })
);
}
#[test]
fn optional_request_fields_are_omitted_until_set() {
let mut req = SearchRequest {
query: QueryPayload::Single(SearchQuery::fuzzy("laptop")),
limit: None,
offset: None,
order_by: None,
sort: None,
time_decay_field: None,
index_weighting: None,
minimum_number_should_match: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(
json,
serde_json::json!({ "query": { "fuzzy": { "ctx": "laptop" } } })
);
req.minimum_number_should_match = Some(2);
req.time_decay_field = Some("published_at".into());
req.index_weighting = Some(1.5);
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["minimum_number_should_match"], 2);
assert_eq!(json["time_decay_field"], "published_at");
assert_eq!(json["index_weighting"], 1.5);
}
#[test]
fn hits_without_a_score_deserialize() {
let json = serde_json::json!({
"status": 200,
"data": {
"hits": [{
"doc": {"title": "Laptop"},
"document_id": "doc-1",
"score": null,
"source_index": "products"
}],
"count": 1,
"time_taken": 0.01
}
});
let resp: SearchResponse = serde_json::from_value(json).unwrap();
assert!(resp.data.hits[0].score.is_none());
}
#[test]
fn search_response_without_facets_deserializes() {
let json = serde_json::json!({
"status": 200,
"data": { "hits": [], "count": 0, "time_taken": 1.0 }
});
let resp: SearchResponse = serde_json::from_value(json).unwrap();
assert!(resp.data.facets.is_none());
}
#[test]
fn search_response_with_facets_deserializes() {
let json = serde_json::json!({
"status": 200,
"data": {
"hits": [],
"count": 0,
"time_taken": 1.0,
"facets": [{
"category": [{
"path": "/electronics",
"count": 12,
"children": [
{ "path": "/electronics/laptops", "count": 5 }
]
}]
}]
}
});
let resp: SearchResponse = serde_json::from_value(json).unwrap();
let facets = resp.data.facets.expect("facets present");
let category = &facets[0]["category"];
assert_eq!(category[0].path, "/electronics");
assert_eq!(category[0].count, 12);
let children = category[0].children.as_ref().expect("children present");
assert_eq!(children[0].path, "/electronics/laptops");
assert_eq!(children[0].count, 5);
assert!(children[0].children.is_none());
}
#[test]
fn summary_stream_event_uses_type_and_data_tagging() {
let event = SummaryStreamEvent::Delta(SummaryDelta {
content: "hello".into(),
});
let json = serde_json::to_value(&event).unwrap();
assert_eq!(
json,
serde_json::json!({"type": "delta", "data": {"content": "hello"}})
);
let parsed: SummaryStreamEvent = serde_json::from_value(json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn summary_stream_event_variants_roundtrip() {
let events = [
SummaryStreamEvent::Metadata(SummaryMetadata {
results_count: 3,
cached: true,
}),
SummaryStreamEvent::Delta(SummaryDelta {
content: "text".into(),
}),
SummaryStreamEvent::Done(SummaryDone { results_count: 3 }),
SummaryStreamEvent::Error(SummaryError {
message: "boom".into(),
}),
];
for event in events {
let json = serde_json::to_string(&event).unwrap();
let parsed: SummaryStreamEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
}
}