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, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ListingCapabilityAudience {
Everyone,
Administrators,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ListingCapabilitiesVO {
pub edit: ListingCapabilityAudience,
pub reply: ListingCapabilityAudience,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct UpdateListingCapabilitiesVO {
pub edit: Option<ListingCapabilityAudience>,
pub reply: Option<ListingCapabilityAudience>,
}
#[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 capabilities: ListingCapabilitiesVO,
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 listing_capabilities_use_snake_case_audiences() {
let capabilities = ListingCapabilitiesVO {
edit: ListingCapabilityAudience::Administrators,
reply: ListingCapabilityAudience::Everyone,
};
let value = serde_json::to_value(capabilities).unwrap();
assert_eq!(
value,
serde_json::json!({
"edit": "administrators",
"reply": "everyone"
})
);
assert_eq!(
serde_json::from_value::<ListingCapabilitiesVO>(value).unwrap(),
capabilities
);
}
#[test]
fn update_listing_capabilities_serializes_set_fields() {
let update = UpdateListingCapabilitiesVO {
edit: Some(ListingCapabilityAudience::Everyone),
reply: Some(ListingCapabilityAudience::Administrators),
};
assert_eq!(
serde_json::to_value(update).unwrap(),
serde_json::json!({
"edit": "everyone",
"reply": "administrators"
})
);
}
#[test]
fn update_listing_capabilities_deserializes_missing_and_null_as_no_change() {
for value in [
serde_json::json!({}),
serde_json::json!({ "edit": null, "reply": null }),
] {
let update: UpdateListingCapabilitiesVO = serde_json::from_value(value).unwrap();
assert_eq!(update.edit, None);
assert_eq!(update.reply, None);
}
let update: UpdateListingCapabilitiesVO =
serde_json::from_value(serde_json::json!({ "edit": "administrators" })).unwrap();
assert_eq!(update.edit, Some(ListingCapabilityAudience::Administrators));
assert_eq!(update.reply, None);
}
#[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
);
}
}