Skip to main content

ahri_tre_protocol/
study.rs

1use crate::{
2    ProtocolVersion, PublicName,
3    domain::{DomainSelector, DomainSummary},
4    pagination::PageRequest,
5    public_error::{ProtocolError, ValidationIssue, ValidationIssueCode},
6    refs::SessionSelector,
7    refs::{
8        AssetRef, DataFileRef, DatasetRef, DomainRef, StudyRef, TagSelector, TagSummary, VersionRef,
9    },
10    search::{PredicateSet, SearchCursorBinding, TagPredicate, TextMode, TextPredicate},
11    session::SessionStatusPayload,
12};
13use serde::{Deserialize, Serialize};
14
15pub const BROWSER_STUDY_ERROR_TARGET_DATASTORE: &str = "datastore";
16pub const BROWSER_STUDY_ERROR_TARGET_STUDY: &str = "study";
17pub const BROWSER_STUDY_ERROR_TARGET_SERVICE: &str = "service";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum StudyTypeSelector {
22    Reference(crate::refs::ObjectRef),
23    Symbol(String),
24}
25impl From<String> for StudyTypeSelector {
26    fn from(value: String) -> Self {
27        Self::Symbol(value)
28    }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
33pub enum StudySelector {
34    Id {
35        study: StudyRef,
36    },
37    Name {
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        domain: Option<DomainSelector>,
40        name: PublicName,
41    },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct StudySummary {
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub study_type: Option<crate::refs::ObjectRef>,
48    pub study: StudyRef,
49    pub name: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub external_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub title: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub description: Option<String>,
56    #[serde(default)]
57    pub domains: Vec<DomainRef>,
58    #[serde(default)]
59    pub tags: Vec<TagSummary>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct StudyDetail {
64    pub summary: StudySummary,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct ListStudiesRequest {
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub session: Option<SessionSelector>,
71    pub page: PageRequest,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct SearchStudiesRequest {
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub session: Option<SessionSelector>,
78    pub predicates: PredicateSet<StudyPredicate>,
79    pub page: PageRequest,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct StudySearchCursorAnchor {
84    pub study: StudyRef,
85    pub name: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct StudySearchCursorQuery {
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub session: Option<SessionSelector>,
92    pub predicates: PredicateSet<StudyPredicate>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct StudySearchCursor {
97    pub binding: SearchCursorBinding<StudySearchCursorQuery>,
98    pub after: StudySearchCursorAnchor,
99}
100
101#[allow(clippy::result_large_err)]
102impl SearchStudiesRequest {
103    pub fn validate(&mut self, protocol_version: &ProtocolVersion) -> Result<(), ProtocolError> {
104        let mut issues = Vec::new();
105        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
106        collect_study_predicate_issues(&mut self.predicates, &mut issues);
107        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
108        collect_cursor_issues(self, protocol_version, &mut issues);
109
110        if issues.is_empty() {
111            Ok(())
112        } else {
113            Err(ProtocolError::validation(
114                "study search request is invalid",
115                issues,
116            ))
117        }
118    }
119
120    pub fn decoded_cursor(
121        &self,
122        protocol_version: &ProtocolVersion,
123    ) -> Result<Option<StudySearchCursorAnchor>, ProtocolError> {
124        match self.page.cursor.as_deref() {
125            None => Ok(None),
126            Some(raw_cursor) => {
127                let cursor: StudySearchCursor =
128                    serde_json::from_str(raw_cursor).map_err(|error| {
129                        ProtocolError::validation(
130                            "study search request is invalid",
131                            vec![ValidationIssue::new(
132                                "body.page.cursor",
133                                ValidationIssueCode::InvalidFormat,
134                                format!("study search cursor is invalid: {error}"),
135                            )],
136                        )
137                    })?;
138                let expected = self.cursor_binding(protocol_version);
139                if cursor.binding != expected {
140                    return Err(ProtocolError::validation(
141                        "study search request is invalid",
142                        vec![ValidationIssue::new(
143                            "body.page.cursor",
144                            ValidationIssueCode::Conflict,
145                            "study search cursor does not match the request predicates, page limit, or protocol version",
146                        )],
147                    ));
148                }
149                Ok(Some(cursor.after))
150            }
151        }
152    }
153
154    pub fn encode_cursor(
155        &self,
156        protocol_version: &ProtocolVersion,
157        after: StudySearchCursorAnchor,
158    ) -> Result<String, ProtocolError> {
159        serde_json::to_string(&StudySearchCursor {
160            binding: self.cursor_binding(protocol_version),
161            after,
162        })
163        .map_err(|error| {
164            ProtocolError::new(
165                crate::public_error::ProtocolErrorCode::Internal,
166                format!("failed to encode study search cursor: {error}"),
167            )
168        })
169    }
170
171    fn cursor_binding(
172        &self,
173        protocol_version: &ProtocolVersion,
174    ) -> SearchCursorBinding<StudySearchCursorQuery> {
175        SearchCursorBinding {
176            query: StudySearchCursorQuery {
177                session: self.session.clone(),
178                predicates: self.predicates.clone(),
179            },
180            page_limit: self.page.limit,
181            protocol_version: protocol_version.to_string(),
182        }
183    }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct GetStudyRequest {
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub session: Option<SessionSelector>,
190    pub study: StudySelector,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct AddStudyRequest {
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub session: Option<SessionSelector>,
198    pub name: PublicName,
199    pub external_id: String,
200    pub study_type: StudyTypeSelector,
201    pub domain: DomainSelector,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub description: Option<String>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct AddStudyDomainRequest {
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub session: Option<SessionSelector>,
210    pub study: StudySelector,
211    pub domain: DomainSelector,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct StudyAccessListRequest {
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub session: Option<SessionSelector>,
218    pub study: StudySelector,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct StudyAccessGrantRequest {
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub session: Option<SessionSelector>,
225    pub study: StudySelector,
226    pub target_user: String,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct StudyAccessRevokeRequest {
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub session: Option<SessionSelector>,
233    pub study: StudySelector,
234    pub target_user: String,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct StudyCustodiansListRequest {
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub session: Option<SessionSelector>,
241    pub study: StudySelector,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct StudyCustodianMutationRequest {
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub session: Option<SessionSelector>,
248    pub study: StudySelector,
249    pub target_user: String,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct StudyDuoListRequest {
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub session: Option<SessionSelector>,
256    pub study: StudySelector,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct StudyDuoRestrictionInput {
261    pub code: Option<String>,
262    pub duo_id: Option<String>,
263    pub qualifier_ontology_namespace: Option<String>,
264    pub qualifier_ontology_id: Option<String>,
265    pub qualifier_text: Option<String>,
266    pub qualifier_date: Option<chrono::NaiveDate>,
267    pub qualifier_integer: Option<i32>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct StudyDuoReplaceRequest {
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub session: Option<SessionSelector>,
274    pub study: StudySelector,
275    #[serde(default)]
276    pub restrictions: Vec<StudyDuoRestrictionInput>,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct BrowserStudyDiscoveryRequest {
281    pub datastore_id: crate::PublicUuid,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct BrowserStudyDetailRequest {
286    pub datastore_id: crate::PublicUuid,
287    pub study_id: crate::PublicUuid,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct BrowserStudySearchRequest {
292    pub datastore_id: crate::PublicUuid,
293    pub search: BrowserStudySearchInput,
294}
295
296/// Browser-specific aggregate Study search input.
297///
298/// The explicit nesting fixes the truth semantics at
299/// `text AND any tag AND any Domain`; an empty group is not applied.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct BrowserStudySearchInput {
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub session: Option<SessionSelector>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub predicates: Option<PredicateSet<StudyPredicate>>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub query: Option<BrowserStudySearchQuery>,
309    pub page: PageRequest,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct BrowserStudySearchQuery {
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub text: Option<BrowserStudyTextQuery>,
316    #[serde(default)]
317    pub facets: BrowserStudyFacetGroups,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct BrowserStudyTextQuery {
322    pub mode: TextMode,
323    pub value: String,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
327pub struct BrowserStudyFacetGroups {
328    #[serde(default)]
329    pub any_tags: Vec<TagSelector>,
330    #[serde(default)]
331    pub any_domains: Vec<DomainSelector>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct BrowserStudyAccessCondition {
336    pub duo_id: String,
337    pub code: String,
338    pub label: String,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub qualifier_ontology_namespace: Option<String>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub qualifier_ontology_id: Option<String>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub qualifier_text: Option<String>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub qualifier_date: Option<chrono::NaiveDate>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub qualifier_integer: Option<i32>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct BrowserStudyAccessRequestReference {
353    pub datastore_id: crate::PublicUuid,
354    pub study: StudyRef,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct BrowserStudyDiscoveryRecord {
359    #[serde(flatten)]
360    pub facts: BrowserStudyFacts,
361    #[serde(default)]
362    pub domains: Vec<DomainSummary>,
363    pub access_request: BrowserStudyAccessRequestReference,
364}
365
366/// Browser-safe Study identity and descriptive facts shared by discovery
367/// surfaces. Governance actions, membership, and search evidence are excluded.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct BrowserStudyFacts {
370    pub study: StudyRef,
371    pub name: String,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub external_id: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub description: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub documentation: Option<String>,
378    #[serde(default)]
379    pub tags: Vec<TagSummary>,
380    #[serde(default)]
381    pub access_conditions: Vec<BrowserStudyAccessCondition>,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct BrowserStudyDiscoveryEmptyState {
386    pub code: String,
387    pub message: String,
388}
389
390/// Browser study discovery is a broad Discovery metadata projection.
391///
392/// It intentionally excludes Content-derived summaries, dataset rows, datafile
393/// bytes, access membership lists, custodian internals, storage paths, raw SQL,
394/// credentials, diagnostics, and runtime handles.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct BrowserStudyDiscoveryResponse {
397    pub authority: String,
398    pub datastore_id: crate::PublicUuid,
399    #[serde(default)]
400    pub studies: Vec<BrowserStudyDiscoveryRecord>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub empty_state: Option<BrowserStudyDiscoveryEmptyState>,
403    pub content_derived_summaries: BrowserContentSummaryPolicy,
404}
405
406/// A browser-safe, datastore-scoped page of Study catalogue search results.
407///
408/// This deliberately reuses the Discovery record projection rather than the
409/// daemon search response, so it cannot disclose daemon session state or
410/// workflow-only metadata.
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412pub struct BrowserStudySearchResponse {
413    pub protocol_version: ProtocolVersion,
414    pub datastore_id: crate::PublicUuid,
415    pub family: String,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub predicates: Option<PredicateSet<StudyPredicate>>,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub query: Option<BrowserStudySearchQuery>,
420    #[serde(default)]
421    pub studies: Vec<BrowserStudySearchRecord>,
422    pub returned_count: u16,
423    pub requested_limit: u16,
424    pub ordering: BrowserSearchOrdering,
425    pub has_more: bool,
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub next_cursor: Option<String>,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub empty_state: Option<BrowserStudySearchEmptyState>,
430}
431
432pub const MAX_BROWSER_STUDY_MATCH_EVIDENCE: usize = 5;
433
434/// A deduplicated Study result plus bounded, browser-safe reasons it matched.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct BrowserStudySearchRecord {
437    #[serde(flatten)]
438    pub discovery: BrowserStudyDiscoveryRecord,
439    #[serde(default)]
440    pub match_evidence: Vec<BrowserStudyMatchEvidence>,
441    pub match_evidence_truncated: bool,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum BrowserStudyMatchedField {
447    Name,
448    ExternalId,
449    Description,
450    Documentation,
451    VersionLabel,
452    VersionNote,
453    Doi,
454    Format,
455    Tag,
456    Domain,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(tag = "kind", rename_all = "snake_case")]
461pub enum BrowserStudyMatchEvidence {
462    Study {
463        study: StudyRef,
464        #[serde(default)]
465        matched_fields: Vec<BrowserStudyMatchedField>,
466    },
467    Dataset {
468        asset: AssetRef,
469        dataset: DatasetRef,
470        version: VersionRef,
471        name: String,
472        #[serde(default)]
473        matched_fields: Vec<BrowserStudyMatchedField>,
474    },
475    Datafile {
476        asset: AssetRef,
477        datafile: DataFileRef,
478        version: VersionRef,
479        name: String,
480        #[serde(default)]
481        matched_fields: Vec<BrowserStudyMatchedField>,
482    },
483}
484
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct BrowserSearchOrdering {
487    pub id: String,
488    pub description: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492pub struct BrowserStudySearchEmptyState {
493    pub code: String,
494    pub message: String,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct BrowserStudySearchCursorQuery {
499    pub datastore_id: crate::PublicUuid,
500    pub family: String,
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub predicates: Option<PredicateSet<StudyPredicate>>,
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub query: Option<BrowserStudySearchQuery>,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct BrowserStudySearchCursor {
509    pub binding: SearchCursorBinding<BrowserStudySearchCursorQuery>,
510    pub after: StudySearchCursorAnchor,
511}
512
513#[allow(clippy::result_large_err)]
514impl BrowserStudySearchInput {
515    pub fn validate(
516        &mut self,
517        datastore_id: crate::PublicUuid,
518        protocol_version: &ProtocolVersion,
519    ) -> Result<(), ProtocolError> {
520        let mut issues = Vec::new();
521        if self.session.is_some() {
522            issues.push(ValidationIssue::new(
523                "body.session",
524                ValidationIssueCode::Unsupported,
525                "browser Study search does not accept daemon session state",
526            ));
527        }
528        match (&mut self.predicates, &mut self.query) {
529            (Some(predicates), None) => {
530                collect_predicate_set_issues("body.predicates", predicates, &mut issues);
531                collect_study_predicate_issues(predicates, &mut issues);
532            }
533            (None, Some(_)) => self.normalize_query(&mut issues),
534            (Some(_), Some(_)) => issues.push(ValidationIssue::new(
535                "body",
536                ValidationIssueCode::Conflict,
537                "browser Study search accepts either predicates or query, not both",
538            )),
539            (None, None) => issues.push(ValidationIssue::new(
540                "body.query",
541                ValidationIssueCode::Required,
542                "browser Study search requires predicates or query",
543            )),
544        }
545        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
546        if issues.is_empty()
547            && let Err(error) = self.decoded_cursor(datastore_id, protocol_version)
548        {
549            issues.extend(error.issues);
550        }
551        if issues.is_empty() {
552            Ok(())
553        } else {
554            Err(ProtocolError::validation(
555                "browser study search request is invalid",
556                issues,
557            ))
558        }
559    }
560
561    pub fn decoded_cursor(
562        &self,
563        datastore_id: crate::PublicUuid,
564        protocol_version: &ProtocolVersion,
565    ) -> Result<Option<StudySearchCursorAnchor>, ProtocolError> {
566        let Some(raw_cursor) = self.page.cursor.as_deref() else {
567            return Ok(None);
568        };
569        let cursor: BrowserStudySearchCursor = serde_json::from_str(raw_cursor).map_err(|_| {
570            ProtocolError::validation(
571                "browser study search request is invalid",
572                vec![ValidationIssue::new(
573                    "body.page.cursor",
574                    ValidationIssueCode::InvalidFormat,
575                    "browser study search cursor is invalid",
576                )],
577            )
578        })?;
579        if cursor.binding != self.cursor_binding(datastore_id, protocol_version) {
580            return Err(ProtocolError::validation(
581                "browser study search request is invalid",
582                vec![ValidationIssue::new(
583                    "body.page.cursor",
584                    ValidationIssueCode::Conflict,
585                    "browser study search cursor does not match the datastore, search family, normalized query, page limit, or protocol version",
586                )],
587            ));
588        }
589        Ok(Some(cursor.after))
590    }
591
592    pub fn encode_cursor(
593        &self,
594        datastore_id: crate::PublicUuid,
595        protocol_version: &ProtocolVersion,
596        after: StudySearchCursorAnchor,
597    ) -> Result<String, ProtocolError> {
598        serde_json::to_string(&BrowserStudySearchCursor {
599            binding: self.cursor_binding(datastore_id, protocol_version),
600            after,
601        })
602        .map_err(|_| {
603            ProtocolError::new(
604                crate::public_error::ProtocolErrorCode::Internal,
605                "failed to encode browser study search cursor",
606            )
607        })
608    }
609
610    fn cursor_binding(
611        &self,
612        datastore_id: crate::PublicUuid,
613        protocol_version: &ProtocolVersion,
614    ) -> SearchCursorBinding<BrowserStudySearchCursorQuery> {
615        SearchCursorBinding {
616            query: BrowserStudySearchCursorQuery {
617                datastore_id,
618                family: "studies".to_string(),
619                predicates: self.predicates.clone(),
620                query: self.query.clone(),
621            },
622            page_limit: self.page.limit,
623            protocol_version: protocol_version.to_string(),
624        }
625    }
626
627    fn normalize_query(&mut self, issues: &mut Vec<ValidationIssue>) {
628        let Some(query) = self.query.as_mut() else {
629            return;
630        };
631        if let Some(text) = query.text.as_mut() {
632            text.value = text.value.trim().to_string();
633            if text.value.is_empty() {
634                issues.push(ValidationIssue::new(
635                    "body.query.text.value",
636                    ValidationIssueCode::TooShort,
637                    "browser Study search text must not be empty",
638                ));
639            } else if text.value.len() > crate::search::MAX_TEXT_VALUE_LENGTH {
640                issues.push(ValidationIssue::new(
641                    "body.query.text.value",
642                    ValidationIssueCode::TooLong,
643                    format!(
644                        "browser Study search text must be at most {} characters",
645                        crate::search::MAX_TEXT_VALUE_LENGTH
646                    ),
647                ));
648            }
649        }
650
651        query.facets.any_tags.sort_by_key(tag_selector_key);
652        query
653            .facets
654            .any_tags
655            .dedup_by(|left, right| tag_selector_key(left) == tag_selector_key(right));
656        query.facets.any_domains.sort_by_key(domain_selector_key);
657        query
658            .facets
659            .any_domains
660            .dedup_by(|left, right| domain_selector_key(left) == domain_selector_key(right));
661
662        let term_count = usize::from(query.text.is_some())
663            + query.facets.any_tags.len()
664            + query.facets.any_domains.len();
665        if term_count == 0 {
666            issues.push(ValidationIssue::new(
667                "body.query",
668                ValidationIssueCode::TooFewItems,
669                "browser Study search query must include text, a tag, or a Domain",
670            ));
671        } else if term_count > crate::search::MAX_SEARCH_PREDICATES {
672            issues.push(ValidationIssue::new(
673                "body.query",
674                ValidationIssueCode::TooManyItems,
675                format!(
676                    "browser Study search query must contain at most {} terms",
677                    crate::search::MAX_SEARCH_PREDICATES
678                ),
679            ));
680        }
681    }
682}
683
684fn tag_selector_key(selector: &TagSelector) -> String {
685    match selector {
686        TagSelector::Id { tag } => format!("id:{}:{:?}:{}", tag.datastore_id, tag.kind, tag.id),
687        TagSelector::Label { scope, label } => format!("label:{scope:?}:{}", label.as_str()),
688    }
689}
690
691fn domain_selector_key(selector: &DomainSelector) -> String {
692    match selector {
693        DomainSelector::Id { domain } => {
694            format!("id:{}:{:?}:{}", domain.datastore_id, domain.kind, domain.id)
695        }
696        DomainSelector::Name { name } => format!("name:{}", name.as_str()),
697    }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
701pub struct BrowserContentSummaryPolicy {
702    pub included: bool,
703    pub reason: String,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(tag = "kind", rename_all = "snake_case")]
708pub enum StudyPredicate {
709    Text(TextPredicate<StudyTextField>),
710    Domain { domain: DomainSelector },
711    Tag(TagPredicate),
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
715#[serde(rename_all = "snake_case")]
716pub enum StudyTextField {
717    Name,
718    Title,
719    Description,
720}
721
722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct StudyRegistrationPayload {
724    pub study: StudySummary,
725    pub domains: Vec<DomainSummary>,
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct StudyAccessGrantSummary {
730    pub study: StudyRef,
731    pub study_name: String,
732    pub user: String,
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub granted_by: Option<String>,
735}
736
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
738pub struct StudyCustodianSummary {
739    pub study: StudyRef,
740    pub study_name: String,
741    pub user: String,
742    pub primary: bool,
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub granted_by: Option<String>,
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct StudyDuoRestrictionSummary {
749    pub study: StudyRef,
750    pub study_name: String,
751    pub duo_id: String,
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub qualifier_ontology_namespace: Option<String>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub qualifier_ontology_id: Option<String>,
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub qualifier_text: Option<String>,
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub qualifier_date: Option<chrono::NaiveDate>,
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub qualifier_integer: Option<i32>,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765pub struct StudyListResponse {
766    pub session: SessionStatusPayload,
767    pub studies: Vec<StudyRegistrationPayload>,
768}
769
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct StudySearchResponse {
772    pub session: SessionStatusPayload,
773    pub studies: Vec<StudySummary>,
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub next_cursor: Option<String>,
776}
777
778#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
779pub struct StudyGetResponse {
780    pub session: SessionStatusPayload,
781    pub registration: StudyRegistrationPayload,
782}
783
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785pub struct StudyAddResponse {
786    pub session: SessionStatusPayload,
787    pub registration: StudyRegistrationPayload,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791pub struct StudyAddDomainResponse {
792    pub session: SessionStatusPayload,
793    pub registration: StudyRegistrationPayload,
794}
795
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
797pub struct StudyAccessListResponse {
798    pub session: SessionStatusPayload,
799    pub study: StudyRef,
800    pub study_name: String,
801    pub grants: Vec<StudyAccessGrantSummary>,
802}
803
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct StudyAccessGrantResponse {
806    pub session: SessionStatusPayload,
807    pub study: StudyRef,
808    pub study_name: String,
809    pub grant: StudyAccessGrantSummary,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
813pub struct StudyAccessRevokeResponse {
814    pub session: SessionStatusPayload,
815    pub study: StudyRef,
816    pub study_name: String,
817    pub target_user: String,
818    pub revoked: bool,
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
822pub struct StudyCustodiansListResponse {
823    pub session: SessionStatusPayload,
824    pub study: StudyRef,
825    pub study_name: String,
826    pub custodians: Vec<StudyCustodianSummary>,
827}
828
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830pub struct StudyCustodianMutationResponse {
831    pub session: SessionStatusPayload,
832    pub study: StudyRef,
833    pub study_name: String,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub custodian: Option<StudyCustodianSummary>,
836    pub target_user: String,
837    pub removed: bool,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub struct StudyDuoListResponse {
842    pub session: SessionStatusPayload,
843    pub study: StudyRef,
844    pub study_name: String,
845    pub restrictions: Vec<StudyDuoRestrictionSummary>,
846}
847
848#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
849pub struct StudyDuoReplaceResponse {
850    pub session: SessionStatusPayload,
851    pub study: StudyRef,
852    pub study_name: String,
853    pub restrictions: Vec<StudyDuoRestrictionSummary>,
854}
855
856fn collect_predicate_set_issues(
857    path: &str,
858    predicates: &PredicateSet<StudyPredicate>,
859    issues: &mut Vec<ValidationIssue>,
860) {
861    let count = predicates.predicates().len();
862    if count == 0 {
863        issues.push(ValidationIssue::new(
864            path,
865            ValidationIssueCode::TooFewItems,
866            "study search predicates must not be empty",
867        ));
868    } else if count > crate::search::MAX_SEARCH_PREDICATES {
869        issues.push(ValidationIssue::new(
870            path,
871            ValidationIssueCode::TooManyItems,
872            format!(
873                "study search predicates must contain at most {} items",
874                crate::search::MAX_SEARCH_PREDICATES
875            ),
876        ));
877    }
878}
879
880fn collect_study_predicate_issues(
881    predicates: &mut PredicateSet<StudyPredicate>,
882    issues: &mut Vec<ValidationIssue>,
883) {
884    for (index, predicate) in predicates.predicates_mut().iter_mut().enumerate() {
885        if let StudyPredicate::Text(text) = predicate
886            && let Err(message) = text.validate()
887        {
888            let code = if text.value.trim().is_empty() {
889                ValidationIssueCode::TooShort
890            } else {
891                ValidationIssueCode::TooLong
892            };
893            issues.push(ValidationIssue::new(
894                format!("body.predicates.predicates[{index}].value"),
895                code,
896                message,
897            ));
898        }
899    }
900}
901
902fn collect_page_issues(path: &str, limit: u16, issues: &mut Vec<ValidationIssue>) {
903    if limit == 0 || limit > crate::search::MAX_PAGE_LIMIT {
904        issues.push(ValidationIssue::new(
905            path,
906            ValidationIssueCode::OutOfRange,
907            if limit == 0 {
908                "page limit must be greater than zero".to_string()
909            } else {
910                format!(
911                    "page limit must be at most {}",
912                    crate::search::MAX_PAGE_LIMIT
913                )
914            },
915        ));
916    }
917}
918
919fn collect_cursor_issues(
920    request: &SearchStudiesRequest,
921    protocol_version: &ProtocolVersion,
922    issues: &mut Vec<ValidationIssue>,
923) {
924    if let Some(raw_cursor) = request.page.cursor.as_deref() {
925        let cursor: StudySearchCursor = match serde_json::from_str(raw_cursor) {
926            Ok(cursor) => cursor,
927            Err(error) => {
928                issues.push(ValidationIssue::new(
929                    "body.page.cursor",
930                    ValidationIssueCode::InvalidFormat,
931                    format!("study search cursor is invalid: {error}"),
932                ));
933                return;
934            }
935        };
936
937        if cursor.binding != request.cursor_binding(protocol_version) {
938            issues.push(ValidationIssue::new(
939                "body.page.cursor",
940                ValidationIssueCode::Conflict,
941                "study search cursor does not match the request predicates, page limit, or protocol version",
942            ));
943        }
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::PublicUuid;
951    use uuid::Uuid;
952
953    #[test]
954    fn asset_aware_browser_query_normalizes_nested_facets_before_cursor_binding() {
955        let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
956        let version = ProtocolVersion::current();
957        let mut request: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
958            "query": {
959                "text": {"mode": "contains", "value": "  sentinel  "},
960                "facets": {
961                    "any_tags": [
962                        {"kind": "label", "scope": "global", "label": "priority"},
963                        {"kind": "label", "scope": "global", "label": "curated"},
964                        {"kind": "label", "scope": "global", "label": "priority"}
965                    ],
966                    "any_domains": [
967                        {"kind": "name", "name": "population"},
968                        {"kind": "name", "name": "demography"},
969                        {"kind": "name", "name": "population"}
970                    ]
971                }
972            },
973            "page": {"limit": 2}
974        }))
975        .unwrap();
976
977        request.validate(datastore, &version).unwrap();
978        let query = request.query.unwrap();
979        assert_eq!(query.text.unwrap().value, "sentinel");
980        assert_eq!(query.facets.any_tags.len(), 2);
981        assert_eq!(query.facets.any_domains.len(), 2);
982        assert_eq!(
983            query.facets.any_tags[0],
984            TagSelector::Label {
985                scope: crate::refs::TagScope::Global,
986                label: PublicName::new("curated").unwrap()
987            }
988        );
989        assert_eq!(
990            query.facets.any_domains[0],
991            DomainSelector::Name {
992                name: PublicName::new("demography").unwrap()
993            }
994        );
995    }
996
997    #[test]
998    fn asset_aware_browser_cursor_rejects_query_datastore_limit_and_version_reuse() {
999        let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
1000        let other_datastore = PublicUuid::from_uuid(Uuid::from_u128(2));
1001        let version = ProtocolVersion::current();
1002        let mut request: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
1003            "query": {
1004                "text": {"mode": "contains", "value": "sentinel"},
1005                "facets": {"any_tags": [], "any_domains": []}
1006            },
1007            "page": {"limit": 2}
1008        }))
1009        .unwrap();
1010        request.validate(datastore, &version).unwrap();
1011        request.page.cursor = Some(
1012            request
1013                .encode_cursor(
1014                    datastore,
1015                    &version,
1016                    StudySearchCursorAnchor {
1017                        study: StudyRef {
1018                            datastore_id: crate::PublicUuid::from_uuid(uuid::Uuid::from_u128(5)),
1019                            kind: crate::refs::ObjectKind::Study,
1020                            id: PublicUuid::from_uuid(Uuid::from_u128(3)),
1021                        },
1022                        name: "Sentinel".to_string(),
1023                    },
1024                )
1025                .unwrap(),
1026        );
1027
1028        assert!(request.validate(datastore, &version).is_ok());
1029        assert!(request.clone().validate(other_datastore, &version).is_err());
1030
1031        let mut changed_query = request.clone();
1032        changed_query
1033            .query
1034            .as_mut()
1035            .unwrap()
1036            .text
1037            .as_mut()
1038            .unwrap()
1039            .value = "other".to_string();
1040        assert!(changed_query.validate(datastore, &version).is_err());
1041
1042        let mut changed_limit = request.clone();
1043        changed_limit.page.limit = 3;
1044        assert!(changed_limit.validate(datastore, &version).is_err());
1045        assert!(
1046            request
1047                .validate(datastore, &ProtocolVersion::parse("1.0.0").unwrap())
1048                .is_err()
1049        );
1050
1051        let raw_cursor = request.page.cursor.take().unwrap();
1052        let mut cursor: BrowserStudySearchCursor = serde_json::from_str(&raw_cursor).unwrap();
1053        cursor.binding.query.family = "datasets".to_string();
1054        request.page.cursor = Some(serde_json::to_string(&cursor).unwrap());
1055        assert!(request.validate(datastore, &version).is_err());
1056    }
1057
1058    #[test]
1059    fn browser_query_preserves_predicate_contract_and_rejects_daemon_session_state() {
1060        let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
1061        let version = ProtocolVersion::current();
1062        let mut legacy: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
1063            "predicates": {
1064                "kind": "all_of",
1065                "predicates": [{
1066                    "kind": "text",
1067                    "field": "name",
1068                    "mode": "contains",
1069                    "value": "  sentinel  "
1070                }]
1071            },
1072            "page": {"limit": 2}
1073        }))
1074        .unwrap();
1075        legacy.validate(datastore, &version).unwrap();
1076        let PredicateSet::AllOf(predicates) = legacy.predicates.unwrap() else {
1077            panic!("expected all-of predicates");
1078        };
1079        let StudyPredicate::Text(text) = &predicates[0] else {
1080            panic!("expected text predicate");
1081        };
1082        assert_eq!(text.value, "sentinel");
1083
1084        let mut request = serde_json::from_value::<BrowserStudySearchInput>(serde_json::json!({
1085            "session": {"name": "daemon"},
1086            "query": {
1087                "text": {"mode": "contains", "value": "sentinel"},
1088                "facets": {"any_tags": [], "any_domains": []}
1089            },
1090            "page": {"limit": 2}
1091        }))
1092        .unwrap();
1093
1094        assert!(request.validate(datastore, &version).is_err());
1095    }
1096}