use crate::types::primitives::*;
use crate::types::serde_helpers::de_present;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Clone)]
pub struct SearchParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub context_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derived_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_period_start_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_period_end_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchResponse {
pub matches: Vec<SearchResult>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub total_estimate: Option<u64>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub next_cursor: Option<String>,
}
impl SearchResponse {
pub fn results(&self) -> &[SearchResult] {
&self.matches
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchResult {
pub ctx_id: CtxId,
pub lineage_id: LineageId,
pub agent_id: AgentDid,
pub title: String,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub summary: Option<String>,
#[serde(rename = "type")]
pub context_type: ContextType,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub domain: Option<String>,
pub created_at: DateTime<Utc>,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<Visibility>,
}
#[derive(Default)]
pub struct SearchParamsBuilder {
inner: SearchParams,
}
use crate::time::fmt_rfc3339_ms;
impl SearchParamsBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn q(mut self, q: impl Into<String>) -> Self {
self.inner.q = Some(q.into());
self
}
pub fn context_type(mut self, t: impl Into<String>) -> Self {
self.inner.context_type = Some(t.into());
self
}
pub fn domain(mut self, d: impl Into<String>) -> Self {
self.inner.domain = Some(d.into());
self
}
pub fn tags(mut self, t: impl Into<String>) -> Self {
self.inner.tags = Some(t.into());
self
}
pub fn agent_id(mut self, a: impl Into<String>) -> Self {
self.inner.agent_id = Some(a.into());
self
}
pub fn derived_from(mut self, c: impl Into<String>) -> Self {
self.inner.derived_from = Some(c.into());
self
}
pub fn derived_from_ctx_id(mut self, c: &CtxId) -> Self {
self.inner.derived_from = Some(c.as_str().to_string());
self
}
pub fn tag(mut self, t: impl Into<String>) -> Self {
let t: String = t.into();
match self.inner.tags.as_mut() {
Some(existing) if !existing.is_empty() => {
existing.push(',');
existing.push_str(&t);
}
_ => self.inner.tags = Some(t),
}
self
}
pub fn created_after(mut self, dt: DateTime<Utc>) -> Self {
self.inner.created_after = Some(fmt_rfc3339_ms(dt));
self
}
pub fn created_before(mut self, dt: DateTime<Utc>) -> Self {
self.inner.created_before = Some(fmt_rfc3339_ms(dt));
self
}
pub fn data_period_start_after(mut self, dt: DateTime<Utc>) -> Self {
self.inner.data_period_start_after = Some(fmt_rfc3339_ms(dt));
self
}
pub fn data_period_end_before(mut self, dt: DateTime<Utc>) -> Self {
self.inner.data_period_end_before = Some(fmt_rfc3339_ms(dt));
self
}
pub fn expires_after(mut self, dt: DateTime<Utc>) -> Self {
self.inner.expires_after = Some(fmt_rfc3339_ms(dt));
self
}
pub fn expires_before(mut self, dt: DateTime<Utc>) -> Self {
self.inner.expires_before = Some(fmt_rfc3339_ms(dt));
self
}
pub fn status(mut self, s: impl Into<String>) -> Self {
self.inner.status = Some(s.into());
self
}
pub fn limit(mut self, l: u32) -> Self {
self.inner.limit = Some(l);
self
}
pub fn cursor(mut self, c: impl Into<String>) -> Self {
self.inner.cursor = Some(c.into());
self
}
pub fn build(self) -> SearchParams {
self.inner
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_result() -> serde_json::Value {
serde_json::json!({
"ctx_id": "acdp://registry.example.com/12345678-1234-4321-8123-123456781234",
"lineage_id": "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111",
"agent_id": "did:web:agents.example.com:test",
"title": "x",
"type": "data_snapshot",
"created_at": "2026-01-01T00:00:00.000Z",
"status": "active",
})
}
#[test]
fn deserializes_with_visibility() {
let mut v = base_result();
v["visibility"] = serde_json::json!("public");
let r: SearchResult = serde_json::from_value(v).unwrap();
assert_eq!(r.visibility, Some(Visibility::Public));
}
#[test]
fn deserializes_without_visibility() {
let r: SearchResult = serde_json::from_value(base_result()).unwrap();
assert_eq!(r.visibility, None, "absence must NOT be coerced to Public");
}
#[test]
fn rejects_unknown_field() {
let mut v = base_result();
v["surprise"] = serde_json::json!("rejected");
let r: Result<SearchResult, _> = serde_json::from_value(v);
assert!(r.is_err(), "unknown field must trigger deny_unknown_fields");
}
#[test]
fn round_trip_with_visibility_public() {
let mut v = base_result();
v["visibility"] = serde_json::json!("restricted");
let r: SearchResult = serde_json::from_value(v).unwrap();
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["visibility"], serde_json::json!("restricted"));
}
#[test]
fn search_response_omits_none_fields() {
let r = SearchResponse {
matches: vec![],
total_estimate: None,
next_cursor: None,
};
let v = serde_json::to_value(&r).unwrap();
let obj = v.as_object().unwrap();
assert!(
!obj.contains_key("total_estimate"),
"total_estimate: None MUST be omitted, not null"
);
assert!(
!obj.contains_key("next_cursor"),
"next_cursor: None MUST be omitted, not null"
);
}
#[test]
fn search_result_omits_none_summary_and_domain() {
let r: SearchResult = serde_json::from_value(base_result()).unwrap();
assert_eq!(r.summary, None);
assert_eq!(r.domain, None);
let v = serde_json::to_value(&r).unwrap();
let obj = v.as_object().unwrap();
assert!(
!obj.contains_key("summary"),
"summary: None MUST be omitted"
);
assert!(!obj.contains_key("domain"), "domain: None MUST be omitted");
}
#[test]
fn search_response_rejects_null_next_cursor() {
let raw = r#"{"matches":[],"total_estimate":0,"next_cursor":null}"#;
let parsed: Result<SearchResponse, _> = serde_json::from_str(raw);
assert!(
parsed.is_err(),
"schema-005: next_cursor:null MUST be rejected, got {parsed:?}"
);
}
#[test]
fn search_result_rejects_null_summary() {
let mut v = base_result();
v["summary"] = serde_json::Value::Null;
let parsed: Result<SearchResult, _> = serde_json::from_value(v);
assert!(
parsed.is_err(),
"schema-006: summary:null MUST be rejected, got {parsed:?}"
);
}
#[test]
fn search_result_rejects_null_domain() {
let mut v = base_result();
v["domain"] = serde_json::Value::Null;
let parsed: Result<SearchResult, _> = serde_json::from_value(v);
assert!(
parsed.is_err(),
"schema-007: domain:null MUST be rejected, got {parsed:?}"
);
}
#[test]
fn search_response_accepts_omitted_optionals() {
let r: SearchResponse = serde_json::from_str(r#"{"matches":[]}"#).unwrap();
assert_eq!(r.total_estimate, None);
assert_eq!(r.next_cursor, None);
}
}