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 #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
160 pub created_at: DateTime<Utc>,
161 pub status: Status,
163 #[serde(skip_serializing_if = "Option::is_none")]
169 pub visibility: Option<Visibility>,
170}
171
172#[derive(Default)]
177pub struct SearchParamsBuilder {
178 inner: SearchParams,
179}
180
181use acdp_primitives::time::fmt_rfc3339_ms;
182
183impl SearchParamsBuilder {
184 pub fn new() -> Self {
186 Self::default()
187 }
188
189 pub fn q(mut self, q: impl Into<String>) -> Self {
191 self.inner.q = Some(q.into());
192 self
193 }
194
195 pub fn context_type(mut self, t: impl Into<String>) -> Self {
197 self.inner.context_type = Some(t.into());
198 self
199 }
200
201 pub fn domain(mut self, d: impl Into<String>) -> Self {
203 self.inner.domain = Some(d.into());
204 self
205 }
206
207 pub fn tags(mut self, t: impl Into<String>) -> Self {
209 self.inner.tags = Some(t.into());
210 self
211 }
212
213 pub fn agent_id(mut self, a: impl Into<String>) -> Self {
215 self.inner.agent_id = Some(a.into());
216 self
217 }
218
219 pub fn derived_from(mut self, c: impl Into<String>) -> Self {
221 self.inner.derived_from = Some(c.into());
222 self
223 }
224
225 pub fn derived_from_ctx_id(mut self, c: &CtxId) -> Self {
228 self.inner.derived_from = Some(c.as_str().to_string());
229 self
230 }
231
232 pub fn tag(mut self, t: impl Into<String>) -> Self {
235 let t: String = t.into();
236 match self.inner.tags.as_mut() {
237 Some(existing) if !existing.is_empty() => {
238 existing.push(',');
239 existing.push_str(&t);
240 }
241 _ => self.inner.tags = Some(t),
242 }
243 self
244 }
245
246 pub fn created_after(mut self, dt: DateTime<Utc>) -> Self {
248 self.inner.created_after = Some(fmt_rfc3339_ms(dt));
249 self
250 }
251
252 pub fn created_before(mut self, dt: DateTime<Utc>) -> Self {
254 self.inner.created_before = Some(fmt_rfc3339_ms(dt));
255 self
256 }
257
258 pub fn data_period_start_after(mut self, dt: DateTime<Utc>) -> Self {
260 self.inner.data_period_start_after = Some(fmt_rfc3339_ms(dt));
261 self
262 }
263
264 pub fn data_period_end_before(mut self, dt: DateTime<Utc>) -> Self {
266 self.inner.data_period_end_before = Some(fmt_rfc3339_ms(dt));
267 self
268 }
269
270 pub fn expires_after(mut self, dt: DateTime<Utc>) -> Self {
272 self.inner.expires_after = Some(fmt_rfc3339_ms(dt));
273 self
274 }
275
276 pub fn expires_before(mut self, dt: DateTime<Utc>) -> Self {
278 self.inner.expires_before = Some(fmt_rfc3339_ms(dt));
279 self
280 }
281
282 pub fn status(mut self, s: impl Into<String>) -> Self {
284 self.inner.status = Some(s.into());
285 self
286 }
287
288 pub fn limit(mut self, l: u32) -> Self {
290 self.inner.limit = Some(l);
291 self
292 }
293
294 pub fn cursor(mut self, c: impl Into<String>) -> Self {
296 self.inner.cursor = Some(c.into());
297 self
298 }
299
300 pub fn build(self) -> SearchParams {
302 self.inner
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn base_result() -> serde_json::Value {
314 serde_json::json!({
315 "ctx_id": "acdp://registry.example.com/12345678-1234-4321-8123-123456781234",
316 "lineage_id": "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111",
317 "agent_id": "did:web:agents.example.com:test",
318 "title": "x",
319 "type": "data_snapshot",
320 "created_at": "2026-01-01T00:00:00.000Z",
321 "status": "active",
322 })
323 }
324
325 #[test]
330 fn deserializes_with_visibility() {
331 let mut v = base_result();
332 v["visibility"] = serde_json::json!("public");
333 let r: SearchResult = serde_json::from_value(v).unwrap();
334 assert_eq!(r.visibility, Some(Visibility::Public));
335 }
336
337 #[test]
338 fn deserializes_without_visibility() {
339 let r: SearchResult = serde_json::from_value(base_result()).unwrap();
340 assert_eq!(r.visibility, None, "absence must NOT be coerced to Public");
341 }
342
343 #[test]
346 fn rejects_unknown_field() {
347 let mut v = base_result();
348 v["surprise"] = serde_json::json!("rejected");
349 let r: Result<SearchResult, _> = serde_json::from_value(v);
350 assert!(r.is_err(), "unknown field must trigger deny_unknown_fields");
351 }
352
353 #[test]
355 fn round_trip_with_visibility_public() {
356 let mut v = base_result();
357 v["visibility"] = serde_json::json!("restricted");
358 let r: SearchResult = serde_json::from_value(v).unwrap();
359 let back = serde_json::to_value(&r).unwrap();
360 assert_eq!(back["visibility"], serde_json::json!("restricted"));
361 }
362
363 #[test]
370 fn search_response_omits_none_fields() {
371 let r = SearchResponse {
372 matches: vec![],
373 total_estimate: None,
374 next_cursor: None,
375 };
376 let v = serde_json::to_value(&r).unwrap();
377 let obj = v.as_object().unwrap();
378 assert!(
379 !obj.contains_key("total_estimate"),
380 "total_estimate: None MUST be omitted, not null"
381 );
382 assert!(
383 !obj.contains_key("next_cursor"),
384 "next_cursor: None MUST be omitted, not null"
385 );
386 }
387
388 #[test]
391 fn search_result_omits_none_summary_and_domain() {
392 let r: SearchResult = serde_json::from_value(base_result()).unwrap();
393 assert_eq!(r.summary, None);
394 assert_eq!(r.domain, None);
395 let v = serde_json::to_value(&r).unwrap();
396 let obj = v.as_object().unwrap();
397 assert!(
398 !obj.contains_key("summary"),
399 "summary: None MUST be omitted"
400 );
401 assert!(!obj.contains_key("domain"), "domain: None MUST be omitted");
402 }
403
404 #[test]
408 fn search_response_rejects_null_next_cursor() {
409 let raw = r#"{"matches":[],"total_estimate":0,"next_cursor":null}"#;
410 let parsed: Result<SearchResponse, _> = serde_json::from_str(raw);
411 assert!(
412 parsed.is_err(),
413 "schema-005: next_cursor:null MUST be rejected, got {parsed:?}"
414 );
415 }
416
417 #[test]
419 fn search_result_rejects_null_summary() {
420 let mut v = base_result();
421 v["summary"] = serde_json::Value::Null;
422 let parsed: Result<SearchResult, _> = serde_json::from_value(v);
423 assert!(
424 parsed.is_err(),
425 "schema-006: summary:null MUST be rejected, got {parsed:?}"
426 );
427 }
428
429 #[test]
431 fn search_result_rejects_null_domain() {
432 let mut v = base_result();
433 v["domain"] = serde_json::Value::Null;
434 let parsed: Result<SearchResult, _> = serde_json::from_value(v);
435 assert!(
436 parsed.is_err(),
437 "schema-007: domain:null MUST be rejected, got {parsed:?}"
438 );
439 }
440
441 #[test]
443 fn search_response_accepts_omitted_optionals() {
444 let r: SearchResponse = serde_json::from_str(r#"{"matches":[]}"#).unwrap();
445 assert_eq!(r.total_estimate, None);
446 assert_eq!(r.next_cursor, None);
447 }
448
449 #[test]
451 fn results_accessor_aliases_matches() {
452 let r: SearchResponse = serde_json::from_value(serde_json::json!({
453 "matches": [base_result()],
454 }))
455 .unwrap();
456 assert_eq!(r.results().len(), 1);
457 assert_eq!(r.results().len(), r.matches.len());
458 assert_eq!(r.results()[0].ctx_id.as_str(), r.matches[0].ctx_id.as_str());
459 }
460
461 #[test]
467 fn builder_empty_serializes_to_empty_object() {
468 let params = SearchParamsBuilder::new().build();
469 let v = serde_json::to_value(¶ms).unwrap();
470 assert_eq!(v, serde_json::json!({}), "no field set ⇒ empty query");
471 }
472
473 #[test]
475 fn builder_new_equals_default() {
476 let a = serde_json::to_value(SearchParamsBuilder::new().build()).unwrap();
477 let b = serde_json::to_value(SearchParamsBuilder::default().build()).unwrap();
478 assert_eq!(a, b);
479 }
480
481 #[test]
484 fn builder_sets_scalar_fields_with_wire_names() {
485 let params = SearchParamsBuilder::new()
486 .q("rainfall")
487 .context_type("data_snapshot")
488 .domain("weather")
489 .agent_id("did:web:agents.example.com:test")
490 .derived_from("acdp://r.example.com/abc")
491 .status("active")
492 .limit(25)
493 .cursor("opaque-cursor")
494 .build();
495 let v = serde_json::to_value(¶ms).unwrap();
496 assert_eq!(v["q"], "rainfall");
497 assert_eq!(v["type"], "data_snapshot", "context_type renames to `type`");
498 assert_eq!(v["domain"], "weather");
499 assert_eq!(v["agent_id"], "did:web:agents.example.com:test");
500 assert_eq!(v["derived_from"], "acdp://r.example.com/abc");
501 assert_eq!(v["status"], "active");
502 assert_eq!(v["limit"], 25);
503 assert_eq!(v["cursor"], "opaque-cursor");
504 }
505
506 #[test]
510 fn builder_tag_accumulates_comma_joined() {
511 let one = SearchParamsBuilder::new().tag("alpha").build();
512 assert_eq!(one.tags.as_deref(), Some("alpha"));
513
514 let many = SearchParamsBuilder::new()
515 .tag("alpha")
516 .tag("beta")
517 .tag("gamma")
518 .build();
519 assert_eq!(many.tags.as_deref(), Some("alpha,beta,gamma"));
520 }
521
522 #[test]
524 fn builder_tag_extends_existing_bulk_tags() {
525 let params = SearchParamsBuilder::new().tags("a,b").tag("c").build();
526 assert_eq!(params.tags.as_deref(), Some("a,b,c"));
527 }
528
529 #[test]
532 fn builder_tag_after_empty_replaces_without_leading_comma() {
533 let params = SearchParamsBuilder::new().tags("").tag("c").build();
534 assert_eq!(params.tags.as_deref(), Some("c"));
535 }
536
537 #[test]
539 fn builder_derived_from_ctx_id_uses_string_form() {
540 let id = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
541 let params = SearchParamsBuilder::new().derived_from_ctx_id(&id).build();
542 assert_eq!(params.derived_from.as_deref(), Some(id.as_str()));
543 }
544
545 #[test]
548 fn builder_date_filters_emit_rfc3339_ms() {
549 use chrono::TimeZone;
550 let dt = Utc.timestamp_opt(1_700_000_000, 123_456_789).unwrap();
552 let params = SearchParamsBuilder::new()
553 .created_after(dt)
554 .created_before(dt)
555 .data_period_start_after(dt)
556 .data_period_end_before(dt)
557 .expires_after(dt)
558 .expires_before(dt)
559 .build();
560 let expected = fmt_rfc3339_ms(dt);
561 assert!(expected.ends_with(".123Z"), "ms-truncated form: {expected}");
562 assert_eq!(params.created_after.as_deref(), Some(expected.as_str()));
563 assert_eq!(params.created_before.as_deref(), Some(expected.as_str()));
564 assert_eq!(
565 params.data_period_start_after.as_deref(),
566 Some(expected.as_str())
567 );
568 assert_eq!(
569 params.data_period_end_before.as_deref(),
570 Some(expected.as_str())
571 );
572 assert_eq!(params.expires_after.as_deref(), Some(expected.as_str()));
573 assert_eq!(params.expires_before.as_deref(), Some(expected.as_str()));
574 }
575}