Skip to main content

acdp_types/
search.rs

1use crate::serde_helpers::de_present;
2use acdp_primitives::primitives::*;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6// ── Keyword search ────────────────────────────────────────────────────────────
7
8/// Query parameters for `GET /contexts/search`.
9///
10/// All fields are optional; unset fields are omitted from the query string.
11/// The registry defaults `status` to `active` when not supplied.
12#[derive(Debug, Default, Serialize, Clone)]
13pub struct SearchParams {
14    /// Full-text search across title, description, domain, tags, agent_id, type.
15    #[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    /// Comma-separated tag list.  All listed tags must be present (AND).
25    #[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    /// Filter for contexts whose `derived_from` includes this `ctx_id`.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub derived_from: Option<String>,
37
38    /// RFC 3339 lower bound on `created_at`.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub created_after: Option<String>,
41
42    /// RFC 3339 upper bound on `created_at`.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub created_before: Option<String>,
45
46    /// RFC 3339 lower bound on `data_period.start`.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub data_period_start_after: Option<String>,
49
50    /// RFC 3339 upper bound on `data_period.end`.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub data_period_end_before: Option<String>,
53
54    /// RFC 3339 lower bound on `expires_at`.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub expires_after: Option<String>,
57
58    /// RFC 3339 upper bound on `expires_at`.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub expires_before: Option<String>,
61
62    /// Filter by lifecycle status.  Defaults to `active`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub status: Option<String>,
65
66    /// Maximum results per page (registry-capped, typically ≤ 100).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub limit: Option<u32>,
69
70    /// Pagination cursor from a previous response.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub cursor: Option<String>,
73}
74
75/// Response from `GET /contexts/search`.
76///
77/// Per `acdp-search-response.schema.json` (additionalProperties: false), the
78/// wrapping array MUST be named `matches`. Conformant consumers MUST reject
79/// responses that emit `results` or any other alternative spelling
80/// (RFC-ACDP-0005 §2.2, fixture vis-003).
81#[derive(Debug, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct SearchResponse {
84    /// Lightweight projections of matching contexts.
85    pub matches: Vec<SearchResult>,
86    /// Estimated total — may be approximate. Omitted (never serialized
87    /// as `null`) when the registry supplies no estimate.
88    #[serde(
89        default,
90        deserialize_with = "de_present",
91        skip_serializing_if = "Option::is_none"
92    )]
93    pub total_estimate: Option<u64>,
94    /// Opaque pagination cursor; absent when there are no more results.
95    ///
96    /// `acdp-search-response.schema.json` types `next_cursor` as a bare
97    /// `string` (not `["string","null"]`): a missing cursor MUST be
98    /// expressed by omitting the key, never by serializing `null`. The
99    /// field is omitted on serialize and an explicit `null` is rejected
100    /// on deserialize (RFC-ACDP-0005 §2.2.1, fixture schema-005).
101    #[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    /// Back-compat accessor; new code should prefer `.matches`.
111    pub fn results(&self) -> &[SearchResult] {
112        &self.matches
113    }
114}
115
116/// A single search result — `match_summary` projection per
117/// `acdp-common.schema.json#/$defs/match_summary`.
118///
119/// Required fields: ctx_id, lineage_id, type, agent_id, title, created_at,
120/// status. Optional: summary, domain, visibility. The full description,
121/// tags, etc. are NOT in this projection — fetch the full Body via the
122/// registry's retrieval endpoint to access them.
123///
124/// `match_summary` is `additionalProperties: false`; deserialization
125/// rejects unknown fields to keep the projection aligned with the schema.
126#[derive(Debug, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct SearchResult {
129    /// Context identifier.
130    pub ctx_id: CtxId,
131    /// Lineage this version belongs to.
132    pub lineage_id: LineageId,
133    /// Producer's signing DID.
134    pub agent_id: AgentDid,
135    /// Short human-readable title.
136    pub title: String,
137    /// Producer-supplied search-summary (≤ 1000 chars). Omitted (never
138    /// `null`) when the context has no summary — `match_summary` types
139    /// it as a bare string; an explicit `null` is rejected (schema-006).
140    #[serde(
141        default,
142        deserialize_with = "de_present",
143        skip_serializing_if = "Option::is_none"
144    )]
145    pub summary: Option<String>,
146    /// Standard or namespaced custom context type.
147    #[serde(rename = "type")]
148    pub context_type: ContextType,
149    /// Subject-domain identifier. Omitted (never `null`) when absent —
150    /// `match_summary` types it as a bare string; an explicit `null` is
151    /// rejected (schema-007).
152    #[serde(
153        default,
154        deserialize_with = "de_present",
155        skip_serializing_if = "Option::is_none"
156    )]
157    pub domain: Option<String>,
158    /// Registry-assigned acceptance time.
159    #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
160    pub created_at: DateTime<Utc>,
161    /// Lifecycle status.
162    pub status: Status,
163    /// Visibility level per RFC-ACDP-0005 §2.2 / RFC-ACDP-0008 §4.5
164    /// disclosure rules. Registries SHOULD include `Public` for public
165    /// results; for `Restricted` / `Private` results the field MUST only
166    /// be present when the requester is authorized. Absence MUST NOT be
167    /// interpreted as `Public`.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub visibility: Option<Visibility>,
170}
171
172// ── Typed builder ────────────────────────────────────────────────────────────
173
174/// Typed builder for [`SearchParams`] that accepts `DateTime<Utc>` for date
175/// filters and ensures they're emitted in RFC 3339 millisecond form.
176#[derive(Default)]
177pub struct SearchParamsBuilder {
178    inner: SearchParams,
179}
180
181use acdp_primitives::time::fmt_rfc3339_ms;
182
183impl SearchParamsBuilder {
184    /// Start an empty builder.
185    pub fn new() -> Self {
186        Self::default()
187    }
188
189    /// Full-text query.
190    pub fn q(mut self, q: impl Into<String>) -> Self {
191        self.inner.q = Some(q.into());
192        self
193    }
194
195    /// Filter on `type`.
196    pub fn context_type(mut self, t: impl Into<String>) -> Self {
197        self.inner.context_type = Some(t.into());
198        self
199    }
200
201    /// Filter on `domain`.
202    pub fn domain(mut self, d: impl Into<String>) -> Self {
203        self.inner.domain = Some(d.into());
204        self
205    }
206
207    /// Filter on tags (comma-separated).
208    pub fn tags(mut self, t: impl Into<String>) -> Self {
209        self.inner.tags = Some(t.into());
210        self
211    }
212
213    /// Filter on `agent_id`.
214    pub fn agent_id(mut self, a: impl Into<String>) -> Self {
215        self.inner.agent_id = Some(a.into());
216        self
217    }
218
219    /// Filter to contexts whose `derived_from` includes this `ctx_id`.
220    pub fn derived_from(mut self, c: impl Into<String>) -> Self {
221        self.inner.derived_from = Some(c.into());
222        self
223    }
224
225    /// Typed alternative to [`Self::derived_from`] — accepts a strongly
226    /// typed [`CtxId`] so callers don't pass arbitrary strings.
227    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    /// Accumulate a tag. Multiple calls are joined with `,` for the
233    /// AND-semantics matcher per RFC-ACDP-0005 §2.1.
234    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    /// Lower bound on `created_at`.
247    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    /// Upper bound on `created_at`.
253    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    /// Lower bound on `data_period.start`.
259    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    /// Upper bound on `data_period.end`.
265    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    /// Lower bound on `expires_at`.
271    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    /// Upper bound on `expires_at`.
277    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    /// Status filter.
283    pub fn status(mut self, s: impl Into<String>) -> Self {
284        self.inner.status = Some(s.into());
285        self
286    }
287
288    /// Result page size cap.
289    pub fn limit(mut self, l: u32) -> Self {
290        self.inner.limit = Some(l);
291        self
292    }
293
294    /// Pagination cursor.
295    pub fn cursor(mut self, c: impl Into<String>) -> Self {
296        self.inner.cursor = Some(c.into());
297        self
298    }
299
300    /// Finalize.
301    pub fn build(self) -> SearchParams {
302        self.inner
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    /// A minimal conformant `match_summary` value. Optional bare-string
311    /// fields (`summary`, `domain`) are *omitted*, not set to `null` —
312    /// emitting `null` for them is schema-invalid (schema-006/007).
313    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    /// BUG-03 — `SearchResult` carries the `visibility` projection field.
326    /// `match_summary` schema marks it optional; a registry SHOULD emit
327    /// it for public results and MUST omit it for restricted/private
328    /// results when the requester is unauthorized.
329    #[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    /// `match_summary` is `additionalProperties: false` — extra fields
344    /// must be rejected so the projection stays aligned with the schema.
345    #[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    /// Round-trip preserves visibility.
354    #[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    // ── BUG-03 — absent-vs-null wire convention ────────────────────────
364
365    /// BUG-03: a `SearchResponse` with no `total_estimate` / `next_cursor`
366    /// MUST omit those keys, never serialize them as `null`
367    /// (`acdp-search-response.schema.json` is `additionalProperties:
368    /// false` and types both as non-nullable).
369    #[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    /// BUG-03: a `SearchResult` with no `summary` / `domain` MUST omit
389    /// those keys (`match_summary` types both as bare strings).
390    #[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    /// schema-005: `next_cursor: null` is schema-invalid — `next_cursor`
405    /// is typed as a bare string, so a strict consumer MUST reject it
406    /// rather than coerce `null` to absent.
407    #[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    /// schema-006: `summary: null` inside a match_summary is rejected.
418    #[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    /// schema-007: `domain: null` inside a match_summary is rejected.
430    #[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    /// Absent optional fields deserialize cleanly to `None`.
442    #[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    /// Back-compat `results()` accessor returns the same slice as `matches`.
450    #[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    // ── SearchParamsBuilder ────────────────────────────────────────────────
462
463    /// An empty builder yields an all-`None` `SearchParams`, which
464    /// serializes to an empty object (every field is
465    /// `skip_serializing_if = "Option::is_none"`).
466    #[test]
467    fn builder_empty_serializes_to_empty_object() {
468        let params = SearchParamsBuilder::new().build();
469        let v = serde_json::to_value(&params).unwrap();
470        assert_eq!(v, serde_json::json!({}), "no field set ⇒ empty query");
471    }
472
473    /// `new()` and `default()` produce the same empty builder.
474    #[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    /// Scalar setters populate the matching query-string keys, and
482    /// `context_type` is renamed to `type` per `SearchParams`.
483    #[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(&params).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    /// `tag()` accumulates into a single comma-joined string for the
507    /// AND-semantics matcher (RFC-ACDP-0005 §2.1); a lone call leaves no
508    /// leading/trailing comma.
509    #[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    /// A prior `tags()` (bulk) value is extended by a later `tag()` call.
523    #[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    /// `tag()` after an explicitly empty `tags("")` replaces rather than
530    /// prefixing a stray comma (the `!existing.is_empty()` guard).
531    #[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    /// `derived_from_ctx_id` accepts a typed `CtxId` and stores its string.
538    #[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    /// Every date-bound setter emits RFC 3339 millisecond form (the
546    /// `fmt_rfc3339_ms` contract), and lands in the right query key.
547    #[test]
548    fn builder_date_filters_emit_rfc3339_ms() {
549        use chrono::TimeZone;
550        // A sub-millisecond instant proves truncation to ms precision.
551        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}