use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{
pagination::{
PaginationParams, default_page, default_page_size, is_default_page, is_default_page_size,
},
resources::ResourceVO,
};
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct CreateListingVO {
pub title: String,
pub description: String,
pub content: String,
pub resources: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct UpdateListingVO {
pub title: String,
pub description: String,
pub content: String,
pub resources: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct UpdateListingVersionStatusVO {
pub status: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ListingsVO {
pub id: String,
pub created_by: Option<String>,
pub title: String,
pub description: String,
pub content: String,
pub banners: Vec<ResourceVO>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ListingVersionVO {
pub id: String,
pub title: String,
pub description: String,
pub content: String,
pub changed_by: Option<String>,
pub created_at: DateTime<Utc>,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct BookmarkVO {
pub listing_id: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum SearchMode {
#[serde(alias = "Fts")]
Fts,
#[serde(alias = "Semantic")]
Semantic,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))]
pub struct ListingsQuery {
#[serde(default = "default_page", skip_serializing_if = "is_default_page")]
pub page: u32,
#[serde(
default = "default_page_size",
skip_serializing_if = "is_default_page_size"
)]
pub page_size: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", param(inline))]
pub mode: Option<SearchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_relevance: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_distance: Option<f32>,
}
impl ListingsQuery {
pub fn normalize(mut self) -> Self {
let pagination = self.pagination().normalize();
self.page = pagination.page;
self.page_size = pagination.page_size;
self
}
pub const fn pagination(&self) -> PaginationParams {
PaginationParams {
page: self.page,
page_size: self.page_size,
}
}
}
impl Default for ListingsQuery {
fn default() -> Self {
Self {
page: default_page(),
page_size: default_page_size(),
mode: None,
q: None,
min_relevance: None,
max_distance: None,
}
}
}
#[cfg(test)]
mod query_tests {
use super::*;
#[test]
fn query_defaults_serialize_to_an_empty_object() {
let query: ListingsQuery = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(query, ListingsQuery::default());
assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({}));
}
#[test]
fn search_mode_accepts_canonical_and_legacy_spellings() {
for value in ["fts", "Fts"] {
let mode: SearchMode = serde_json::from_value(serde_json::json!(value)).unwrap();
assert_eq!(mode, SearchMode::Fts);
}
for value in ["semantic", "Semantic"] {
let mode: SearchMode = serde_json::from_value(serde_json::json!(value)).unwrap();
assert_eq!(mode, SearchMode::Semantic);
}
}
#[test]
fn browse_and_mode_queries_roundtrip_all_public_fields() {
let browse = ListingsQuery {
page: 2,
page_size: 10,
mode: None,
q: None,
min_relevance: None,
max_distance: None,
};
let json = serde_json::to_value(&browse).unwrap();
assert!(json.get("mode").is_none());
assert_eq!(
serde_json::from_value::<ListingsQuery>(json).unwrap(),
browse
);
let semantic = ListingsQuery {
page: 1,
page_size: 20,
mode: Some(SearchMode::Semantic),
q: Some("knowledge graph".into()),
min_relevance: None,
max_distance: Some(0.42),
};
let json = serde_json::to_value(&semantic).unwrap();
assert_eq!(json["mode"], "semantic");
assert_eq!(
serde_json::from_value::<ListingsQuery>(json).unwrap(),
semantic
);
}
#[test]
fn bookmark_roundtrips_json() {
let bookmark = BookmarkVO {
listing_id: "listing".into(),
created_at: DateTime::parse_from_rfc3339("2026-08-04T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
};
let value = serde_json::to_value(&bookmark).unwrap();
assert_eq!(
serde_json::from_value::<BookmarkVO>(value).unwrap(),
bookmark
);
}
}