1use crate::serde_helpers::de_present;
2use acdp_primitives::primitives::*;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Default, Serialize, Clone)]
13pub struct SearchParams {
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub q: Option<String>,
17
18 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
19 pub context_type: Option<String>,
20
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub domain: Option<String>,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub tags: Option<String>,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub agent_id: Option<String>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub schema_uri: Option<String>,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub derived_from: Option<String>,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub created_after: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub created_before: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub data_period_start_after: Option<String>,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub data_period_end_before: Option<String>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub expires_after: Option<String>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub expires_before: Option<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub status: Option<String>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub limit: Option<u32>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub cursor: Option<String>,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct SearchResponse {
84 pub matches: Vec<SearchResult>,
86 #[serde(
89 default,
90 deserialize_with = "de_present",
91 skip_serializing_if = "Option::is_none"
92 )]
93 pub total_estimate: Option<u64>,
94 #[serde(
102 default,
103 deserialize_with = "de_present",
104 skip_serializing_if = "Option::is_none"
105 )]
106 pub next_cursor: Option<String>,
107}
108
109impl SearchResponse {
110 pub fn results(&self) -> &[SearchResult] {
112 &self.matches
113 }
114}
115
116#[derive(Debug, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct SearchResult {
129 pub ctx_id: CtxId,
131 pub lineage_id: LineageId,
133 pub agent_id: AgentDid,
135 pub title: String,
137 #[serde(
141 default,
142 deserialize_with = "de_present",
143 skip_serializing_if = "Option::is_none"
144 )]
145 pub summary: Option<String>,
146 #[serde(rename = "type")]
148 pub context_type: ContextType,
149 #[serde(
153 default,
154 deserialize_with = "de_present",
155 skip_serializing_if = "Option::is_none"
156 )]
157 pub domain: Option<String>,
158 pub created_at: DateTime<Utc>,
160 pub status: Status,
162 #[serde(skip_serializing_if = "Option::is_none")]
168 pub visibility: Option<Visibility>,
169}
170
171#[derive(Default)]
176pub struct SearchParamsBuilder {
177 inner: SearchParams,
178}
179
180use acdp_primitives::time::fmt_rfc3339_ms;
181
182impl SearchParamsBuilder {
183 pub fn new() -> Self {
185 Self::default()
186 }
187
188 pub fn q(mut self, q: impl Into<String>) -> Self {
190 self.inner.q = Some(q.into());
191 self
192 }
193
194 pub fn context_type(mut self, t: impl Into<String>) -> Self {
196 self.inner.context_type = Some(t.into());
197 self
198 }
199
200 pub fn domain(mut self, d: impl Into<String>) -> Self {
202 self.inner.domain = Some(d.into());
203 self
204 }
205
206 pub fn tags(mut self, t: impl Into<String>) -> Self {
208 self.inner.tags = Some(t.into());
209 self
210 }
211
212 pub fn agent_id(mut self, a: impl Into<String>) -> Self {
214 self.inner.agent_id = Some(a.into());
215 self
216 }
217
218 pub fn derived_from(mut self, c: impl Into<String>) -> Self {
220 self.inner.derived_from = Some(c.into());
221 self
222 }
223
224 pub fn derived_from_ctx_id(mut self, c: &CtxId) -> Self {
227 self.inner.derived_from = Some(c.as_str().to_string());
228 self
229 }
230
231 pub fn tag(mut self, t: impl Into<String>) -> Self {
234 let t: String = t.into();
235 match self.inner.tags.as_mut() {
236 Some(existing) if !existing.is_empty() => {
237 existing.push(',');
238 existing.push_str(&t);
239 }
240 _ => self.inner.tags = Some(t),
241 }
242 self
243 }
244
245 pub fn created_after(mut self, dt: DateTime<Utc>) -> Self {
247 self.inner.created_after = Some(fmt_rfc3339_ms(dt));
248 self
249 }
250
251 pub fn created_before(mut self, dt: DateTime<Utc>) -> Self {
253 self.inner.created_before = Some(fmt_rfc3339_ms(dt));
254 self
255 }
256
257 pub fn data_period_start_after(mut self, dt: DateTime<Utc>) -> Self {
259 self.inner.data_period_start_after = Some(fmt_rfc3339_ms(dt));
260 self
261 }
262
263 pub fn data_period_end_before(mut self, dt: DateTime<Utc>) -> Self {
265 self.inner.data_period_end_before = Some(fmt_rfc3339_ms(dt));
266 self
267 }
268
269 pub fn expires_after(mut self, dt: DateTime<Utc>) -> Self {
271 self.inner.expires_after = Some(fmt_rfc3339_ms(dt));
272 self
273 }
274
275 pub fn expires_before(mut self, dt: DateTime<Utc>) -> Self {
277 self.inner.expires_before = Some(fmt_rfc3339_ms(dt));
278 self
279 }
280
281 pub fn status(mut self, s: impl Into<String>) -> Self {
283 self.inner.status = Some(s.into());
284 self
285 }
286
287 pub fn limit(mut self, l: u32) -> Self {
289 self.inner.limit = Some(l);
290 self
291 }
292
293 pub fn cursor(mut self, c: impl Into<String>) -> Self {
295 self.inner.cursor = Some(c.into());
296 self
297 }
298
299 pub fn build(self) -> SearchParams {
301 self.inner
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn base_result() -> serde_json::Value {
313 serde_json::json!({
314 "ctx_id": "acdp://registry.example.com/12345678-1234-4321-8123-123456781234",
315 "lineage_id": "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111",
316 "agent_id": "did:web:agents.example.com:test",
317 "title": "x",
318 "type": "data_snapshot",
319 "created_at": "2026-01-01T00:00:00.000Z",
320 "status": "active",
321 })
322 }
323
324 #[test]
329 fn deserializes_with_visibility() {
330 let mut v = base_result();
331 v["visibility"] = serde_json::json!("public");
332 let r: SearchResult = serde_json::from_value(v).unwrap();
333 assert_eq!(r.visibility, Some(Visibility::Public));
334 }
335
336 #[test]
337 fn deserializes_without_visibility() {
338 let r: SearchResult = serde_json::from_value(base_result()).unwrap();
339 assert_eq!(r.visibility, None, "absence must NOT be coerced to Public");
340 }
341
342 #[test]
345 fn rejects_unknown_field() {
346 let mut v = base_result();
347 v["surprise"] = serde_json::json!("rejected");
348 let r: Result<SearchResult, _> = serde_json::from_value(v);
349 assert!(r.is_err(), "unknown field must trigger deny_unknown_fields");
350 }
351
352 #[test]
354 fn round_trip_with_visibility_public() {
355 let mut v = base_result();
356 v["visibility"] = serde_json::json!("restricted");
357 let r: SearchResult = serde_json::from_value(v).unwrap();
358 let back = serde_json::to_value(&r).unwrap();
359 assert_eq!(back["visibility"], serde_json::json!("restricted"));
360 }
361
362 #[test]
369 fn search_response_omits_none_fields() {
370 let r = SearchResponse {
371 matches: vec![],
372 total_estimate: None,
373 next_cursor: None,
374 };
375 let v = serde_json::to_value(&r).unwrap();
376 let obj = v.as_object().unwrap();
377 assert!(
378 !obj.contains_key("total_estimate"),
379 "total_estimate: None MUST be omitted, not null"
380 );
381 assert!(
382 !obj.contains_key("next_cursor"),
383 "next_cursor: None MUST be omitted, not null"
384 );
385 }
386
387 #[test]
390 fn search_result_omits_none_summary_and_domain() {
391 let r: SearchResult = serde_json::from_value(base_result()).unwrap();
392 assert_eq!(r.summary, None);
393 assert_eq!(r.domain, None);
394 let v = serde_json::to_value(&r).unwrap();
395 let obj = v.as_object().unwrap();
396 assert!(
397 !obj.contains_key("summary"),
398 "summary: None MUST be omitted"
399 );
400 assert!(!obj.contains_key("domain"), "domain: None MUST be omitted");
401 }
402
403 #[test]
407 fn search_response_rejects_null_next_cursor() {
408 let raw = r#"{"matches":[],"total_estimate":0,"next_cursor":null}"#;
409 let parsed: Result<SearchResponse, _> = serde_json::from_str(raw);
410 assert!(
411 parsed.is_err(),
412 "schema-005: next_cursor:null MUST be rejected, got {parsed:?}"
413 );
414 }
415
416 #[test]
418 fn search_result_rejects_null_summary() {
419 let mut v = base_result();
420 v["summary"] = serde_json::Value::Null;
421 let parsed: Result<SearchResult, _> = serde_json::from_value(v);
422 assert!(
423 parsed.is_err(),
424 "schema-006: summary:null MUST be rejected, got {parsed:?}"
425 );
426 }
427
428 #[test]
430 fn search_result_rejects_null_domain() {
431 let mut v = base_result();
432 v["domain"] = serde_json::Value::Null;
433 let parsed: Result<SearchResult, _> = serde_json::from_value(v);
434 assert!(
435 parsed.is_err(),
436 "schema-007: domain:null MUST be rejected, got {parsed:?}"
437 );
438 }
439
440 #[test]
442 fn search_response_accepts_omitted_optionals() {
443 let r: SearchResponse = serde_json::from_str(r#"{"matches":[]}"#).unwrap();
444 assert_eq!(r.total_estimate, None);
445 assert_eq!(r.next_cursor, None);
446 }
447
448 #[test]
450 fn results_accessor_aliases_matches() {
451 let r: SearchResponse = serde_json::from_value(serde_json::json!({
452 "matches": [base_result()],
453 }))
454 .unwrap();
455 assert_eq!(r.results().len(), 1);
456 assert_eq!(r.results().len(), r.matches.len());
457 assert_eq!(r.results()[0].ctx_id.as_str(), r.matches[0].ctx_id.as_str());
458 }
459
460 #[test]
466 fn builder_empty_serializes_to_empty_object() {
467 let params = SearchParamsBuilder::new().build();
468 let v = serde_json::to_value(¶ms).unwrap();
469 assert_eq!(v, serde_json::json!({}), "no field set ⇒ empty query");
470 }
471
472 #[test]
474 fn builder_new_equals_default() {
475 let a = serde_json::to_value(SearchParamsBuilder::new().build()).unwrap();
476 let b = serde_json::to_value(SearchParamsBuilder::default().build()).unwrap();
477 assert_eq!(a, b);
478 }
479
480 #[test]
483 fn builder_sets_scalar_fields_with_wire_names() {
484 let params = SearchParamsBuilder::new()
485 .q("rainfall")
486 .context_type("data_snapshot")
487 .domain("weather")
488 .agent_id("did:web:agents.example.com:test")
489 .derived_from("acdp://r.example.com/abc")
490 .status("active")
491 .limit(25)
492 .cursor("opaque-cursor")
493 .build();
494 let v = serde_json::to_value(¶ms).unwrap();
495 assert_eq!(v["q"], "rainfall");
496 assert_eq!(v["type"], "data_snapshot", "context_type renames to `type`");
497 assert_eq!(v["domain"], "weather");
498 assert_eq!(v["agent_id"], "did:web:agents.example.com:test");
499 assert_eq!(v["derived_from"], "acdp://r.example.com/abc");
500 assert_eq!(v["status"], "active");
501 assert_eq!(v["limit"], 25);
502 assert_eq!(v["cursor"], "opaque-cursor");
503 }
504
505 #[test]
509 fn builder_tag_accumulates_comma_joined() {
510 let one = SearchParamsBuilder::new().tag("alpha").build();
511 assert_eq!(one.tags.as_deref(), Some("alpha"));
512
513 let many = SearchParamsBuilder::new()
514 .tag("alpha")
515 .tag("beta")
516 .tag("gamma")
517 .build();
518 assert_eq!(many.tags.as_deref(), Some("alpha,beta,gamma"));
519 }
520
521 #[test]
523 fn builder_tag_extends_existing_bulk_tags() {
524 let params = SearchParamsBuilder::new().tags("a,b").tag("c").build();
525 assert_eq!(params.tags.as_deref(), Some("a,b,c"));
526 }
527
528 #[test]
531 fn builder_tag_after_empty_replaces_without_leading_comma() {
532 let params = SearchParamsBuilder::new().tags("").tag("c").build();
533 assert_eq!(params.tags.as_deref(), Some("c"));
534 }
535
536 #[test]
538 fn builder_derived_from_ctx_id_uses_string_form() {
539 let id = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
540 let params = SearchParamsBuilder::new().derived_from_ctx_id(&id).build();
541 assert_eq!(params.derived_from.as_deref(), Some(id.as_str()));
542 }
543
544 #[test]
547 fn builder_date_filters_emit_rfc3339_ms() {
548 use chrono::TimeZone;
549 let dt = Utc.timestamp_opt(1_700_000_000, 123_456_789).unwrap();
551 let params = SearchParamsBuilder::new()
552 .created_after(dt)
553 .created_before(dt)
554 .data_period_start_after(dt)
555 .data_period_end_before(dt)
556 .expires_after(dt)
557 .expires_before(dt)
558 .build();
559 let expected = fmt_rfc3339_ms(dt);
560 assert!(expected.ends_with(".123Z"), "ms-truncated form: {expected}");
561 assert_eq!(params.created_after.as_deref(), Some(expected.as_str()));
562 assert_eq!(params.created_before.as_deref(), Some(expected.as_str()));
563 assert_eq!(
564 params.data_period_start_after.as_deref(),
565 Some(expected.as_str())
566 );
567 assert_eq!(
568 params.data_period_end_before.as_deref(),
569 Some(expected.as_str())
570 );
571 assert_eq!(params.expires_after.as_deref(), Some(expected.as_str()));
572 assert_eq!(params.expires_before.as_deref(), Some(expected.as_str()));
573 }
574}