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    pub created_at: DateTime<Utc>,
160    /// Lifecycle status.
161    pub status: Status,
162    /// Visibility level per RFC-ACDP-0005 §2.2 / RFC-ACDP-0008 §4.5
163    /// disclosure rules. Registries SHOULD include `Public` for public
164    /// results; for `Restricted` / `Private` results the field MUST only
165    /// be present when the requester is authorized. Absence MUST NOT be
166    /// interpreted as `Public`.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub visibility: Option<Visibility>,
169}
170
171// ── Typed builder ────────────────────────────────────────────────────────────
172
173/// Typed builder for [`SearchParams`] that accepts `DateTime<Utc>` for date
174/// filters and ensures they're emitted in RFC 3339 millisecond form.
175#[derive(Default)]
176pub struct SearchParamsBuilder {
177    inner: SearchParams,
178}
179
180use acdp_primitives::time::fmt_rfc3339_ms;
181
182impl SearchParamsBuilder {
183    /// Start an empty builder.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Full-text query.
189    pub fn q(mut self, q: impl Into<String>) -> Self {
190        self.inner.q = Some(q.into());
191        self
192    }
193
194    /// Filter on `type`.
195    pub fn context_type(mut self, t: impl Into<String>) -> Self {
196        self.inner.context_type = Some(t.into());
197        self
198    }
199
200    /// Filter on `domain`.
201    pub fn domain(mut self, d: impl Into<String>) -> Self {
202        self.inner.domain = Some(d.into());
203        self
204    }
205
206    /// Filter on tags (comma-separated).
207    pub fn tags(mut self, t: impl Into<String>) -> Self {
208        self.inner.tags = Some(t.into());
209        self
210    }
211
212    /// Filter on `agent_id`.
213    pub fn agent_id(mut self, a: impl Into<String>) -> Self {
214        self.inner.agent_id = Some(a.into());
215        self
216    }
217
218    /// Filter to contexts whose `derived_from` includes this `ctx_id`.
219    pub fn derived_from(mut self, c: impl Into<String>) -> Self {
220        self.inner.derived_from = Some(c.into());
221        self
222    }
223
224    /// Typed alternative to [`Self::derived_from`] — accepts a strongly
225    /// typed [`CtxId`] so callers don't pass arbitrary strings.
226    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    /// Accumulate a tag. Multiple calls are joined with `,` for the
232    /// AND-semantics matcher per RFC-ACDP-0005 §2.1.
233    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    /// Lower bound on `created_at`.
246    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    /// Upper bound on `created_at`.
252    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    /// Lower bound on `data_period.start`.
258    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    /// Upper bound on `data_period.end`.
264    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    /// Lower bound on `expires_at`.
270    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    /// Upper bound on `expires_at`.
276    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    /// Status filter.
282    pub fn status(mut self, s: impl Into<String>) -> Self {
283        self.inner.status = Some(s.into());
284        self
285    }
286
287    /// Result page size cap.
288    pub fn limit(mut self, l: u32) -> Self {
289        self.inner.limit = Some(l);
290        self
291    }
292
293    /// Pagination cursor.
294    pub fn cursor(mut self, c: impl Into<String>) -> Self {
295        self.inner.cursor = Some(c.into());
296        self
297    }
298
299    /// Finalize.
300    pub fn build(self) -> SearchParams {
301        self.inner
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// A minimal conformant `match_summary` value. Optional bare-string
310    /// fields (`summary`, `domain`) are *omitted*, not set to `null` —
311    /// emitting `null` for them is schema-invalid (schema-006/007).
312    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    /// BUG-03 — `SearchResult` carries the `visibility` projection field.
325    /// `match_summary` schema marks it optional; a registry SHOULD emit
326    /// it for public results and MUST omit it for restricted/private
327    /// results when the requester is unauthorized.
328    #[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    /// `match_summary` is `additionalProperties: false` — extra fields
343    /// must be rejected so the projection stays aligned with the schema.
344    #[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    /// Round-trip preserves visibility.
353    #[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    // ── BUG-03 — absent-vs-null wire convention ────────────────────────
363
364    /// BUG-03: a `SearchResponse` with no `total_estimate` / `next_cursor`
365    /// MUST omit those keys, never serialize them as `null`
366    /// (`acdp-search-response.schema.json` is `additionalProperties:
367    /// false` and types both as non-nullable).
368    #[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    /// BUG-03: a `SearchResult` with no `summary` / `domain` MUST omit
388    /// those keys (`match_summary` types both as bare strings).
389    #[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    /// schema-005: `next_cursor: null` is schema-invalid — `next_cursor`
404    /// is typed as a bare string, so a strict consumer MUST reject it
405    /// rather than coerce `null` to absent.
406    #[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    /// schema-006: `summary: null` inside a match_summary is rejected.
417    #[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    /// schema-007: `domain: null` inside a match_summary is rejected.
429    #[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    /// Absent optional fields deserialize cleanly to `None`.
441    #[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    /// Back-compat `results()` accessor returns the same slice as `matches`.
449    #[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    // ── SearchParamsBuilder ────────────────────────────────────────────────
461
462    /// An empty builder yields an all-`None` `SearchParams`, which
463    /// serializes to an empty object (every field is
464    /// `skip_serializing_if = "Option::is_none"`).
465    #[test]
466    fn builder_empty_serializes_to_empty_object() {
467        let params = SearchParamsBuilder::new().build();
468        let v = serde_json::to_value(&params).unwrap();
469        assert_eq!(v, serde_json::json!({}), "no field set ⇒ empty query");
470    }
471
472    /// `new()` and `default()` produce the same empty builder.
473    #[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    /// Scalar setters populate the matching query-string keys, and
481    /// `context_type` is renamed to `type` per `SearchParams`.
482    #[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(&params).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    /// `tag()` accumulates into a single comma-joined string for the
506    /// AND-semantics matcher (RFC-ACDP-0005 §2.1); a lone call leaves no
507    /// leading/trailing comma.
508    #[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    /// A prior `tags()` (bulk) value is extended by a later `tag()` call.
522    #[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    /// `tag()` after an explicitly empty `tags("")` replaces rather than
529    /// prefixing a stray comma (the `!existing.is_empty()` guard).
530    #[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    /// `derived_from_ctx_id` accepts a typed `CtxId` and stores its string.
537    #[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    /// Every date-bound setter emits RFC 3339 millisecond form (the
545    /// `fmt_rfc3339_ms` contract), and lands in the right query key.
546    #[test]
547    fn builder_date_filters_emit_rfc3339_ms() {
548        use chrono::TimeZone;
549        // A sub-millisecond instant proves truncation to ms precision.
550        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}