1use crate::browser_resource_discovery::{
4 BrowserResourceDiscoveryCapability, BrowserResourceDiscoveryError,
5 BrowserResourceDiscoverySource,
6};
7
8use ahri_tre_protocol::{
9 ProtocolError, ProtocolVersion, PublicUuid,
10 domain::DomainSummary,
11 refs::{StudyRef, TagRef, TagScope, TagSummary, VersionRef},
12 search::PredicateSet,
13 study::{
14 BrowserContentSummaryPolicy, BrowserSearchOrdering, BrowserStudyAccessCondition,
15 BrowserStudyAccessRequestReference, BrowserStudyDiscoveryEmptyState,
16 BrowserStudyDiscoveryRecord, BrowserStudyDiscoveryResponse, BrowserStudyFacts,
17 BrowserStudyMatchEvidence, BrowserStudyMatchedField, BrowserStudySearchEmptyState,
18 BrowserStudySearchInput, BrowserStudySearchRecord, BrowserStudySearchResponse,
19 MAX_BROWSER_STUDY_MATCH_EVIDENCE, StudyPredicate, StudySearchCursorAnchor, StudyTextField,
20 },
21};
22use std::fmt;
23use thiserror::Error;
24use uuid::Uuid;
25
26pub const BROWSER_STUDY_DISCOVERY_AUTHORITY: &str = "tre_browser.study_discovery";
27
28#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
29pub enum BrowserStudyDiscoveryError {
30 #[error("selected datastore identity is invalid")]
31 InvalidDatastoreIdentity,
32 #[error("selected datastore is not available for browser discovery")]
33 DatastoreUnavailable,
34 #[error("browser study discovery authority is unavailable")]
35 AuthorityUnavailable,
36 #[error("study was not found in the selected datastore")]
37 StudyNotFound,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct BrowserStudyDiscoverySource {
42 pub datastore_id: PublicUuid,
43 pub studies: Vec<BrowserStudyDiscoverySourceRecord>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct BrowserStudyDiscoverySourceRecord {
48 pub study: ahri_tre_pgmeta::PgStudyReadModel,
49 pub domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
50 pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
51 pub access_conditions: Vec<ahri_tre_pgmeta::PgDuoRestrictionReadModel>,
52}
53
54pub trait BrowserStudyDiscoveryCapability: fmt::Debug + Send + Sync {
56 fn discover_studies(
57 &self,
58 datastore_id: PublicUuid,
59 ) -> Result<BrowserStudyDiscoverySource, BrowserStudyDiscoveryError>;
60}
61
62pub fn discover_browser_studies(
63 capability: &dyn BrowserStudyDiscoveryCapability,
64 datastore_id: PublicUuid,
65) -> Result<BrowserStudyDiscoveryResponse, BrowserStudyDiscoveryError> {
66 capability
67 .discover_studies(datastore_id)
68 .map(browser_study_discovery_response)
69}
70
71pub fn discover_browser_study(
72 capability: &dyn BrowserStudyDiscoveryCapability,
73 datastore_id: PublicUuid,
74 study_id: PublicUuid,
75) -> Result<BrowserStudyDiscoveryResponse, BrowserStudyDiscoveryError> {
76 browser_study_get_response(capability.discover_studies(datastore_id)?, study_id)
77}
78
79#[allow(clippy::result_large_err)]
80pub fn search_browser_studies(
81 capability: &dyn BrowserResourceDiscoveryCapability,
82 datastore_id: PublicUuid,
83 request: &BrowserStudySearchInput,
84 protocol_version: &ProtocolVersion,
85) -> Result<BrowserStudySearchResponse, BrowserStudyWorkflowError> {
86 validate_search_references(datastore_id, request, protocol_version)
87 .map_err(BrowserStudyWorkflowError::InvalidRequest)?;
88 let sources = capability.discover_catalogue_search_resources(datastore_id)?;
89 browser_study_search_response(datastore_id, sources, request, protocol_version)
90 .map_err(BrowserStudyWorkflowError::InvalidRequest)
91}
92
93#[allow(clippy::result_large_err)]
94fn validate_search_references(
95 datastore_id: PublicUuid,
96 request: &BrowserStudySearchInput,
97 protocol_version: &ProtocolVersion,
98) -> Result<(), ProtocolError> {
99 use crate::browser_catalogue_search::{validate_domain, validate_tag};
100 if let Some(predicates) = &request.predicates {
101 for predicate in predicates.predicates() {
102 match predicate {
103 ahri_tre_protocol::study::StudyPredicate::Domain { domain } => {
104 validate_domain(datastore_id, domain)?
105 }
106 ahri_tre_protocol::study::StudyPredicate::Tag(tag) => {
107 validate_tag(datastore_id, &tag.selector)?
108 }
109 _ => {}
110 }
111 }
112 }
113 if let Some(query) = &request.query {
114 for domain in &query.facets.any_domains {
115 validate_domain(datastore_id, domain)?;
116 }
117 for tag in &query.facets.any_tags {
118 validate_tag(datastore_id, tag)?;
119 }
120 }
121 if let Some(cursor) = request.decoded_cursor(datastore_id, protocol_version)? {
122 cursor
123 .study
124 .validate_context(datastore_id, ahri_tre_protocol::refs::ObjectKind::Study)?;
125 }
126 Ok(())
127}
128
129#[derive(Debug, Error, Clone, PartialEq, Eq)]
130pub enum BrowserStudyWorkflowError {
131 #[error(transparent)]
132 Discovery(#[from] BrowserResourceDiscoveryError),
133 #[error("browser Study search request is invalid")]
134 InvalidRequest(ProtocolError),
135}
136
137pub fn browser_study_discovery_response(
138 source: BrowserStudyDiscoverySource,
139) -> BrowserStudyDiscoveryResponse {
140 let datastore_id = source.datastore_id;
141 let studies = source
142 .studies
143 .into_iter()
144 .map(|record| browser_study_discovery_record(datastore_id, record))
145 .collect::<Vec<_>>();
146 let empty_state = studies.is_empty().then(|| BrowserStudyDiscoveryEmptyState {
147 code: "no_browser_visible_studies".to_string(),
148 message: "No browser-visible studies are available in this datastore.".to_string(),
149 });
150
151 BrowserStudyDiscoveryResponse {
152 authority: BROWSER_STUDY_DISCOVERY_AUTHORITY.to_string(),
153 datastore_id,
154 studies,
155 empty_state,
156 content_derived_summaries: BrowserContentSummaryPolicy {
157 included: false,
158 reason: "Content-derived summaries are excluded from broad Discovery metadata."
159 .to_string(),
160 },
161 }
162}
163
164pub fn browser_study_get_response(
165 source: BrowserStudyDiscoverySource,
166 study_id: PublicUuid,
167) -> Result<BrowserStudyDiscoveryResponse, BrowserStudyDiscoveryError> {
168 let filtered = BrowserStudyDiscoverySource {
169 datastore_id: source.datastore_id,
170 studies: source
171 .studies
172 .into_iter()
173 .filter(|record| PublicUuid::from_uuid(record.study.study_id.0) == study_id)
174 .collect(),
175 };
176
177 if filtered.studies.is_empty() {
178 Err(BrowserStudyDiscoveryError::StudyNotFound)
179 } else {
180 Ok(browser_study_discovery_response(filtered))
181 }
182}
183
184#[allow(clippy::result_large_err)]
185pub fn browser_study_search_response(
186 datastore_id: PublicUuid,
187 sources: Vec<BrowserResourceDiscoverySource>,
188 request: &BrowserStudySearchInput,
189 protocol_version: &ProtocolVersion,
190) -> Result<BrowserStudySearchResponse, ProtocolError> {
191 validate_search_references(datastore_id, request, protocol_version)?;
192 let after = request.decoded_cursor(datastore_id, protocol_version)?;
193 let mut studies = sources
194 .into_iter()
195 .filter_map(|source| study_candidate(source, request))
196 .collect::<Vec<_>>();
197 studies.sort_by_key(study_sort_key);
198 if let Some(after) = after {
199 studies.retain(|record| study_sort_key(record) > cursor_sort_key(&after));
200 }
201
202 let limit = usize::from(request.page.limit);
203 let has_more = studies.len() > limit;
204 let page = studies.into_iter().take(limit).collect::<Vec<_>>();
205 let next_cursor = if has_more {
206 match page.last() {
207 Some(record) => Some(request.encode_cursor(
208 datastore_id,
209 protocol_version,
210 study_cursor_anchor(datastore_id, record),
211 )?),
212 None => None,
213 }
214 } else {
215 None
216 };
217 let returned_count = u16::try_from(page.len()).expect("page length fits u16");
218 let empty_state = page.is_empty().then(|| BrowserStudySearchEmptyState {
219 code: "no_matching_browser_visible_studies".to_string(),
220 message: "No browser-visible studies match the active search filters.".to_string(),
221 });
222
223 Ok(BrowserStudySearchResponse {
224 protocol_version: protocol_version.clone(),
225 datastore_id,
226 family: "studies".to_string(),
227 predicates: request.predicates.clone(),
228 query: request.query.clone(),
229 studies: page
230 .into_iter()
231 .map(BrowserStudyCandidate::record)
232 .collect(),
233 returned_count,
234 requested_limit: request.page.limit,
235 ordering: BrowserSearchOrdering {
236 id: "study_name_then_identity".to_string(),
237 description: "Studies are ordered by canonical name, then stable identity.".to_string(),
238 },
239 has_more,
240 next_cursor,
241 empty_state,
242 })
243}
244
245#[derive(Debug)]
246struct BrowserStudyCandidate {
247 source: BrowserResourceDiscoverySource,
248 evidence: Vec<BrowserStudyMatchEvidence>,
249 evidence_truncated: bool,
250}
251
252impl BrowserStudyCandidate {
253 fn record(self) -> BrowserStudySearchRecord {
254 let datastore_id = self.source.datastore_id;
255 let record = BrowserStudyDiscoverySourceRecord {
256 study: self.source.study,
257 domains: self.source.domains,
258 tags: self.source.study_tags,
259 access_conditions: self.source.study_access_conditions,
260 };
261 BrowserStudySearchRecord {
262 discovery: browser_study_discovery_record(datastore_id, record),
263 match_evidence: self.evidence,
264 match_evidence_truncated: self.evidence_truncated,
265 }
266 }
267}
268
269fn browser_study_discovery_record(
270 datastore_id: PublicUuid,
271 record: BrowserStudyDiscoverySourceRecord,
272) -> BrowserStudyDiscoveryRecord {
273 let study = StudyRef {
274 datastore_id,
275 kind: ahri_tre_protocol::refs::ObjectKind::Study,
276 id: PublicUuid::from_uuid(record.study.study_id.0),
277 };
278 BrowserStudyDiscoveryRecord {
279 facts: browser_study_facts(datastore_id, &record),
280 domains: record
281 .domains
282 .into_iter()
283 .map(|value| domain_summary(datastore_id, value))
284 .collect(),
285 access_request: BrowserStudyAccessRequestReference {
286 datastore_id,
287 study,
288 },
289 }
290}
291
292fn study_candidate(
293 mut source: BrowserResourceDiscoverySource,
294 request: &BrowserStudySearchInput,
295) -> Option<BrowserStudyCandidate> {
296 let datastore_id = source.datastore_id;
297 if request.query.is_none() {
298 return legacy_study_candidate(source, request);
299 }
300 let query = request.query.as_ref().expect("query branch checked");
301 source
302 .datasets
303 .sort_by_key(|record| asset_evidence_sort_key(&record.asset));
304 source
305 .datafiles
306 .sort_by_key(|record| asset_evidence_sort_key(&record.asset));
307
308 let domain_matches = query.facets.any_domains.is_empty()
309 || query.facets.any_domains.iter().any(|selector| {
310 source
311 .domains
312 .iter()
313 .any(|domain| domain_matches(domain, selector))
314 });
315 if !domain_matches {
316 return None;
317 }
318
319 let tag_matches = query.facets.any_tags.is_empty()
320 || source_tags(&source).any(|tag| {
321 query
322 .facets
323 .any_tags
324 .iter()
325 .any(|selector| tag_matches_selector(tag, selector))
326 });
327 if !tag_matches {
328 return None;
329 }
330
331 let mut evidence = Vec::new();
332 let study_fields = study_matched_fields(&source, request);
333 if !study_fields.is_empty() {
334 evidence.push(BrowserStudyMatchEvidence::Study {
335 study: StudyRef {
336 datastore_id,
337 kind: ahri_tre_protocol::refs::ObjectKind::Study,
338 id: PublicUuid::from_uuid(source.study.study_id.0),
339 },
340 matched_fields: study_fields,
341 });
342 }
343 for dataset in &source.datasets {
344 for matched in
345 asset_version_matches(&dataset.asset, &dataset.tags, &dataset.versions, request)
346 {
347 let version = &dataset.versions[matched.version_index];
348 evidence.push(BrowserStudyMatchEvidence::Dataset {
349 asset: ahri_tre_protocol::refs::AssetRef {
350 datastore_id,
351 kind: ahri_tre_protocol::refs::ObjectKind::Asset,
352 id: PublicUuid::from_uuid(dataset.asset.asset_id.0),
353 },
354 dataset: ahri_tre_protocol::refs::DatasetRef {
355 datastore_id,
356 kind: ahri_tre_protocol::refs::ObjectKind::Asset,
357 id: PublicUuid::from_uuid(dataset.asset.asset_id.0),
358 },
359 version: VersionRef {
360 datastore_id,
361 kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
362 id: PublicUuid::from_uuid(version.version.version_id.0),
363 },
364 name: dataset.asset.name.clone(),
365 matched_fields: matched.fields,
366 });
367 }
368 }
369 for datafile in &source.datafiles {
370 for matched in
371 asset_version_matches(&datafile.asset, &datafile.tags, &datafile.versions, request)
372 {
373 let version = &datafile.versions[matched.version_index];
374 evidence.push(BrowserStudyMatchEvidence::Datafile {
375 asset: ahri_tre_protocol::refs::AssetRef {
376 datastore_id,
377 kind: ahri_tre_protocol::refs::ObjectKind::Asset,
378 id: PublicUuid::from_uuid(datafile.asset.asset_id.0),
379 },
380 datafile: ahri_tre_protocol::refs::DataFileRef {
381 datastore_id,
382 kind: ahri_tre_protocol::refs::ObjectKind::Asset,
383 id: PublicUuid::from_uuid(datafile.asset.asset_id.0),
384 },
385 version: VersionRef {
386 datastore_id,
387 kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
388 id: PublicUuid::from_uuid(version.version.version_id.0),
389 },
390 name: datafile.asset.name.clone(),
391 matched_fields: matched.fields,
392 });
393 }
394 }
395
396 let text_matches = query.text.is_none()
397 || evidence.iter().any(|item| {
398 matched_fields(item).iter().any(|field| {
399 *field != BrowserStudyMatchedField::Tag
400 && *field != BrowserStudyMatchedField::Domain
401 })
402 });
403 if !text_matches {
404 return None;
405 }
406 if evidence.is_empty() {
407 evidence.push(BrowserStudyMatchEvidence::Study {
408 study: StudyRef {
409 datastore_id,
410 kind: ahri_tre_protocol::refs::ObjectKind::Study,
411 id: PublicUuid::from_uuid(source.study.study_id.0),
412 },
413 matched_fields: Vec::new(),
414 });
415 }
416 let evidence_truncated = evidence.len() > MAX_BROWSER_STUDY_MATCH_EVIDENCE;
417 evidence.truncate(MAX_BROWSER_STUDY_MATCH_EVIDENCE);
418 Some(BrowserStudyCandidate {
419 source,
420 evidence,
421 evidence_truncated,
422 })
423}
424
425fn legacy_study_candidate(
426 source: BrowserResourceDiscoverySource,
427 request: &BrowserStudySearchInput,
428) -> Option<BrowserStudyCandidate> {
429 let datastore_id = source.datastore_id;
430 let predicates = request
431 .predicates
432 .as_ref()
433 .expect("validated browser search has predicates or a query");
434 let evaluated = predicates
435 .predicates()
436 .iter()
437 .map(|predicate| legacy_predicate_match(&source, predicate))
438 .collect::<Vec<_>>();
439 let matches = match predicates {
440 PredicateSet::AllOf(_) => evaluated.iter().all(|(matches, _)| *matches),
441 PredicateSet::AnyOf(_) => evaluated.iter().any(|(matches, _)| *matches),
442 };
443 if !matches {
444 return None;
445 }
446
447 let mut matched_fields = evaluated
448 .into_iter()
449 .filter_map(|(matches, field)| matches.then_some(field))
450 .collect::<Vec<_>>();
451 matched_fields.sort_by_key(|field| format!("{field:?}"));
452 matched_fields.dedup();
453 Some(BrowserStudyCandidate {
454 evidence: vec![BrowserStudyMatchEvidence::Study {
455 study: StudyRef {
456 datastore_id,
457 kind: ahri_tre_protocol::refs::ObjectKind::Study,
458 id: PublicUuid::from_uuid(source.study.study_id.0),
459 },
460 matched_fields,
461 }],
462 source,
463 evidence_truncated: false,
464 })
465}
466
467fn legacy_predicate_match(
468 source: &BrowserResourceDiscoverySource,
469 predicate: &StudyPredicate,
470) -> (bool, BrowserStudyMatchedField) {
471 match predicate {
472 StudyPredicate::Text(text) => {
473 let (candidate, field) = match text.field {
474 StudyTextField::Name => (
475 Some(source.study.name.as_str()),
476 BrowserStudyMatchedField::Name,
477 ),
478 StudyTextField::Title => (
479 source.study.documentation.as_deref(),
480 BrowserStudyMatchedField::Documentation,
481 ),
482 StudyTextField::Description => (
483 source.study.description.as_deref(),
484 BrowserStudyMatchedField::Description,
485 ),
486 };
487 (
488 candidate.is_some_and(|candidate| {
489 ahri_tre_core::search_text_matches(
490 candidate,
491 match text.mode {
492 ahri_tre_protocol::search::TextMode::Exact => {
493 ahri_tre_core::SearchTextMode::Exact
494 }
495 ahri_tre_protocol::search::TextMode::Prefix => {
496 ahri_tre_core::SearchTextMode::Prefix
497 }
498 ahri_tre_protocol::search::TextMode::Contains => {
499 ahri_tre_core::SearchTextMode::Contains
500 }
501 },
502 &text.value,
503 )
504 }),
505 field,
506 )
507 }
508 StudyPredicate::Domain { domain } => (
509 source
510 .domains
511 .iter()
512 .any(|candidate| domain_matches(candidate, domain)),
513 BrowserStudyMatchedField::Domain,
514 ),
515 StudyPredicate::Tag(tag) => (
516 source
517 .study_tags
518 .iter()
519 .any(|candidate| tag_matches_selector(candidate, &tag.selector)),
520 BrowserStudyMatchedField::Tag,
521 ),
522 }
523}
524
525fn study_matched_fields(
526 source: &BrowserResourceDiscoverySource,
527 request: &BrowserStudySearchInput,
528) -> Vec<BrowserStudyMatchedField> {
529 let query = request
530 .query
531 .as_ref()
532 .expect("asset-aware query is present");
533 let mut fields = Vec::new();
534 if let Some(text) = query.text.as_ref() {
535 push_text_match(
536 &mut fields,
537 BrowserStudyMatchedField::Name,
538 &source.study.name,
539 text,
540 );
541 push_optional_text_match(
542 &mut fields,
543 BrowserStudyMatchedField::ExternalId,
544 source.study.external_id.as_deref(),
545 text,
546 );
547 push_optional_text_match(
548 &mut fields,
549 BrowserStudyMatchedField::Description,
550 source.study.description.as_deref(),
551 text,
552 );
553 push_optional_text_match(
554 &mut fields,
555 BrowserStudyMatchedField::Documentation,
556 source.study.documentation.as_deref(),
557 text,
558 );
559 }
560 if !query.facets.any_domains.is_empty()
561 && source.domains.iter().any(|domain| {
562 query
563 .facets
564 .any_domains
565 .iter()
566 .any(|selector| domain_matches(domain, selector))
567 })
568 {
569 fields.push(BrowserStudyMatchedField::Domain);
570 }
571 if tags_match_selected(&source.study_tags, request) {
572 fields.push(BrowserStudyMatchedField::Tag);
573 }
574 fields
575}
576
577struct AssetVersionMatch {
578 version_index: usize,
579 fields: Vec<BrowserStudyMatchedField>,
580}
581
582trait AssetVersionEvidenceSource {
583 fn version(&self) -> &ahri_tre_pgmeta::PgAssetVersionReadModel;
584 fn tags(&self) -> &[ahri_tre_pgmeta::PgTagReadModel];
585
586 fn available_for_latest_selection(&self) -> bool {
587 true
588 }
589
590 fn format(&self) -> Option<&str> {
591 None
592 }
593}
594
595impl AssetVersionEvidenceSource
596 for crate::browser_resource_discovery::BrowserDatasetVersionDiscoverySourceRecord
597{
598 fn version(&self) -> &ahri_tre_pgmeta::PgAssetVersionReadModel {
599 &self.version
600 }
601
602 fn tags(&self) -> &[ahri_tre_pgmeta::PgTagReadModel] {
603 &self.tags
604 }
605
606 fn available_for_latest_selection(&self) -> bool {
607 self.withdrawn_at.is_none()
608 }
609}
610
611impl AssetVersionEvidenceSource
612 for crate::browser_resource_discovery::BrowserDataFileVersionDiscoverySourceRecord
613{
614 fn version(&self) -> &ahri_tre_pgmeta::PgAssetVersionReadModel {
615 &self.version
616 }
617
618 fn tags(&self) -> &[ahri_tre_pgmeta::PgTagReadModel] {
619 &self.tags
620 }
621
622 fn format(&self) -> Option<&str> {
623 Some(&self.datafile.format)
624 }
625}
626
627fn asset_version_matches<V: AssetVersionEvidenceSource>(
628 asset: &ahri_tre_pgmeta::PgAssetReadModel,
629 asset_tags: &[ahri_tre_pgmeta::PgTagReadModel],
630 versions: &[V],
631 request: &BrowserStudySearchInput,
632) -> Vec<AssetVersionMatch> {
633 let query = request
634 .query
635 .as_ref()
636 .expect("asset-aware query is present");
637 let latest_index = versions
638 .iter()
639 .position(|source| {
640 source.available_for_latest_selection() && source.version().is_latest.unwrap_or(false)
641 })
642 .or_else(|| {
643 versions
644 .iter()
645 .rposition(AssetVersionEvidenceSource::available_for_latest_selection)
646 });
647 let mut asset_fields = Vec::new();
648 if let Some(text) = query.text.as_ref() {
649 push_text_match(
650 &mut asset_fields,
651 BrowserStudyMatchedField::Name,
652 &asset.name,
653 text,
654 );
655 push_optional_text_match(
656 &mut asset_fields,
657 BrowserStudyMatchedField::Description,
658 asset.description.as_deref(),
659 text,
660 );
661 }
662 if tags_match_selected(asset_tags, request) {
663 asset_fields.push(BrowserStudyMatchedField::Tag);
664 }
665
666 let mut matches = versions
667 .iter()
668 .enumerate()
669 .filter_map(|(version_index, source)| {
670 let version = source.version();
671 let mut fields = Vec::new();
672 if let Some(text) = query.text.as_ref() {
673 push_optional_text_match(
674 &mut fields,
675 BrowserStudyMatchedField::VersionLabel,
676 version.version_label.as_deref(),
677 text,
678 );
679 push_optional_text_match(
680 &mut fields,
681 BrowserStudyMatchedField::VersionNote,
682 version.version_note.as_deref(),
683 text,
684 );
685 push_optional_text_match(
686 &mut fields,
687 BrowserStudyMatchedField::Doi,
688 version.doi.as_deref(),
689 text,
690 );
691 push_optional_text_match(
692 &mut fields,
693 BrowserStudyMatchedField::Format,
694 source.format(),
695 text,
696 );
697 }
698 if tags_match_selected(source.tags(), request) {
699 fields.push(BrowserStudyMatchedField::Tag);
700 }
701 (!fields.is_empty()).then_some(AssetVersionMatch {
702 version_index,
703 fields,
704 })
705 })
706 .collect::<Vec<_>>();
707
708 if !asset_fields.is_empty()
709 && let Some(latest_index) = latest_index
710 {
711 if let Some(latest) = matches
712 .iter_mut()
713 .find(|matched| matched.version_index == latest_index)
714 {
715 for field in asset_fields {
716 if !latest.fields.contains(&field) {
717 latest.fields.push(field);
718 }
719 }
720 } else {
721 matches.insert(
722 0,
723 AssetVersionMatch {
724 version_index: latest_index,
725 fields: asset_fields,
726 },
727 );
728 }
729 }
730 matches
731}
732
733fn push_optional_text_match(
734 fields: &mut Vec<BrowserStudyMatchedField>,
735 field: BrowserStudyMatchedField,
736 candidate: Option<&str>,
737 text: &ahri_tre_protocol::study::BrowserStudyTextQuery,
738) {
739 if let Some(candidate) = candidate {
740 push_text_match(fields, field, candidate, text);
741 }
742}
743
744fn push_text_match(
745 fields: &mut Vec<BrowserStudyMatchedField>,
746 field: BrowserStudyMatchedField,
747 candidate: &str,
748 text: &ahri_tre_protocol::study::BrowserStudyTextQuery,
749) {
750 if text_matches(candidate, text) && !fields.contains(&field) {
751 fields.push(field);
752 }
753}
754
755fn text_matches(candidate: &str, text: &ahri_tre_protocol::study::BrowserStudyTextQuery) -> bool {
756 ahri_tre_core::search_text_matches(
757 candidate,
758 match text.mode {
759 ahri_tre_protocol::search::TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
760 ahri_tre_protocol::search::TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
761 ahri_tre_protocol::search::TextMode::Contains => {
762 ahri_tre_core::SearchTextMode::Contains
763 }
764 },
765 &text.value,
766 )
767}
768
769fn tags_match_selected(
770 tags: &[ahri_tre_pgmeta::PgTagReadModel],
771 request: &BrowserStudySearchInput,
772) -> bool {
773 let query = request
774 .query
775 .as_ref()
776 .expect("asset-aware query is present");
777 !query.facets.any_tags.is_empty()
778 && tags.iter().any(|tag| {
779 query
780 .facets
781 .any_tags
782 .iter()
783 .any(|selector| tag_matches_selector(tag, selector))
784 })
785}
786
787fn source_tags(
788 source: &BrowserResourceDiscoverySource,
789) -> impl Iterator<Item = &ahri_tre_pgmeta::PgTagReadModel> {
790 source
791 .study_tags
792 .iter()
793 .chain(source.datasets.iter().flat_map(|record| {
794 record.tags.iter().chain(
795 record
796 .versions
797 .iter()
798 .flat_map(|version| version.tags.iter()),
799 )
800 }))
801 .chain(source.datafiles.iter().flat_map(|record| {
802 record.tags.iter().chain(
803 record
804 .versions
805 .iter()
806 .flat_map(|version| version.tags.iter()),
807 )
808 }))
809}
810
811fn tag_matches_selector(
812 tag: &ahri_tre_pgmeta::PgTagReadModel,
813 selector: &ahri_tre_protocol::refs::TagSelector,
814) -> bool {
815 match selector {
816 ahri_tre_protocol::refs::TagSelector::Id { tag: expected } => {
817 ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id) == expected.id
818 }
819 ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
820 *scope == TagScope::Global && tag.name == label.as_str()
821 }
822 }
823}
824
825fn domain_matches(
826 domain: &ahri_tre_pgmeta::PgDomainReadModel,
827 selector: &ahri_tre_protocol::domain::DomainSelector,
828) -> bool {
829 match selector {
830 ahri_tre_protocol::domain::DomainSelector::Id { domain: expected } => {
831 ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", domain.domain_id.0)
832 == expected.id
833 }
834 ahri_tre_protocol::domain::DomainSelector::Name { name } => domain.name == name.as_str(),
835 }
836}
837
838fn matched_fields(evidence: &BrowserStudyMatchEvidence) -> &[BrowserStudyMatchedField] {
839 match evidence {
840 BrowserStudyMatchEvidence::Study { matched_fields, .. }
841 | BrowserStudyMatchEvidence::Dataset { matched_fields, .. }
842 | BrowserStudyMatchEvidence::Datafile { matched_fields, .. } => matched_fields,
843 }
844}
845
846fn asset_evidence_sort_key(record: &ahri_tre_pgmeta::PgAssetReadModel) -> (String, String, Uuid) {
847 (
848 record.name.to_ascii_lowercase(),
849 record.name.clone(),
850 record.asset_id.0,
851 )
852}
853
854fn study_sort_key(record: &BrowserStudyCandidate) -> (String, String, Uuid) {
855 (
856 record.source.study.name.as_str().to_ascii_lowercase(),
857 record.source.study.name.as_str().to_string(),
858 record.source.study.study_id.0,
859 )
860}
861
862fn cursor_sort_key(anchor: &StudySearchCursorAnchor) -> (String, String, Uuid) {
863 (
864 anchor.name.to_ascii_lowercase(),
865 anchor.name.clone(),
866 anchor.study.id.as_uuid(),
867 )
868}
869
870fn study_cursor_anchor(
871 datastore_id: PublicUuid,
872 record: &BrowserStudyCandidate,
873) -> StudySearchCursorAnchor {
874 StudySearchCursorAnchor {
875 study: StudyRef {
876 datastore_id,
877 kind: ahri_tre_protocol::refs::ObjectKind::Study,
878 id: PublicUuid::from_uuid(record.source.study.study_id.0),
879 },
880 name: record.source.study.name.as_str().to_string(),
881 }
882}
883
884pub fn browser_study_facts(
885 datastore_id: PublicUuid,
886 record: &BrowserStudyDiscoverySourceRecord,
887) -> BrowserStudyFacts {
888 BrowserStudyFacts {
889 study: StudyRef {
890 datastore_id,
891 kind: ahri_tre_protocol::refs::ObjectKind::Study,
892 id: PublicUuid::from_uuid(record.study.study_id.0),
893 },
894 name: record.study.name.clone(),
895 external_id: record.study.external_id.clone(),
896 description: record.study.description.clone(),
897 documentation: record.study.documentation.clone(),
898 tags: record
899 .tags
900 .iter()
901 .cloned()
902 .map(|tag| tag_summary(datastore_id, tag))
903 .collect(),
904 access_conditions: record
905 .access_conditions
906 .iter()
907 .cloned()
908 .map(access_condition)
909 .collect(),
910 }
911}
912
913fn domain_summary(
914 datastore_id: PublicUuid,
915 domain: ahri_tre_pgmeta::PgDomainReadModel,
916) -> DomainSummary {
917 DomainSummary {
918 domain: crate::projections::domain_ref(datastore_id, domain.domain_id),
919 name: domain.name,
920 description: domain.description,
921 uri: domain.uri,
922 }
923}
924
925fn tag_summary(datastore_id: PublicUuid, tag: ahri_tre_pgmeta::PgTagReadModel) -> TagSummary {
926 TagSummary {
927 tag: TagRef {
928 datastore_id,
929 kind: ahri_tre_protocol::refs::ObjectKind::Tag,
930 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
931 },
932 scope: TagScope::Global,
933 label: tag.name,
934 }
935}
936
937fn access_condition(
938 condition: ahri_tre_pgmeta::PgDuoRestrictionReadModel,
939) -> BrowserStudyAccessCondition {
940 BrowserStudyAccessCondition {
941 duo_id: condition.duo_id,
942 code: condition.duo_code,
943 label: condition.label,
944 qualifier_ontology_namespace: condition.qualifier_ontology_namespace,
945 qualifier_ontology_id: condition.qualifier_ontology_id,
946 qualifier_text: condition.qualifier_text,
947 qualifier_date: condition.qualifier_date,
948 qualifier_integer: condition.qualifier_integer,
949 }
950}
951
952#[cfg(test)]
953mod asset_aware_search_tests {
954 use super::*;
955 use crate::browser_resource_discovery::{
956 BrowserDataFileDiscoverySourceRecord, BrowserDataFileMetadataSource,
957 BrowserDataFileVersionDiscoverySourceRecord, BrowserDatasetDiscoverySourceRecord,
958 BrowserDatasetVersionDiscoverySourceRecord, BrowserResourceDiscoverySource,
959 };
960 use ahri_tre_protocol::{
961 pagination::PageRequest,
962 study::{
963 BrowserStudyFacetGroups, BrowserStudySearchInput, BrowserStudySearchQuery,
964 BrowserStudyTextQuery,
965 },
966 };
967 use ahri_tre_types::{AssetId, AssetType, DomainId, StudyId, VersionId};
968
969 #[test]
970 fn returned_study_domain_uses_the_analyst_reference_and_can_filter_again() {
971 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
972 let domain = source(datastore_id).domains.remove(0);
973 let returned = domain_summary(datastore_id, domain.clone()).domain;
974 assert_eq!(
975 returned,
976 crate::projections::domain_ref(datastore_id, domain.domain_id)
977 );
978 assert_eq!(
979 ahri_tre_protocol::refs::decode_scoped_integer_ref("domain", returned.id),
980 Some(domain.domain_id.0)
981 );
982 assert!(domain_matches(
983 &domain,
984 &ahri_tre_protocol::domain::DomainSelector::Id { domain: returned }
985 ));
986 }
987
988 #[test]
989 fn aggregate_search_matches_study_dataset_and_datafile_once_with_nested_facets() {
990 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
991 let mut request = BrowserStudySearchInput {
992 session: None,
993 predicates: None,
994 query: Some(BrowserStudySearchQuery {
995 text: Some(BrowserStudyTextQuery {
996 mode: ahri_tre_protocol::search::TextMode::Contains,
997 value: "sentinel".to_string(),
998 }),
999 facets: BrowserStudyFacetGroups {
1000 any_tags: vec![
1001 ahri_tre_protocol::refs::TagSelector::Label {
1002 scope: TagScope::Global,
1003 label: ahri_tre_protocol::PublicName::new("missing-tag").unwrap(),
1004 },
1005 ahri_tre_protocol::refs::TagSelector::Label {
1006 scope: TagScope::Global,
1007 label: ahri_tre_protocol::PublicName::new("curated").unwrap(),
1008 },
1009 ],
1010 any_domains: vec![
1011 ahri_tre_protocol::domain::DomainSelector::Name {
1012 name: ahri_tre_protocol::PublicName::new("missing-domain").unwrap(),
1013 },
1014 ahri_tre_protocol::domain::DomainSelector::Name {
1015 name: ahri_tre_protocol::PublicName::new("demography").unwrap(),
1016 },
1017 ],
1018 },
1019 }),
1020 page: PageRequest {
1021 cursor: None,
1022 limit: 10,
1023 },
1024 };
1025 request
1026 .validate(datastore_id, &ProtocolVersion::current())
1027 .unwrap();
1028
1029 let response = browser_study_search_response(
1030 datastore_id,
1031 vec![source(datastore_id)],
1032 &request,
1033 &ProtocolVersion::current(),
1034 )
1035 .unwrap();
1036
1037 assert_eq!(response.studies.len(), 1);
1038 assert_eq!(response.studies[0].match_evidence.len(), 3);
1039 assert!(matches!(
1040 response.studies[0].match_evidence[0],
1041 ahri_tre_protocol::study::BrowserStudyMatchEvidence::Study { .. }
1042 ));
1043 assert!(matches!(
1044 response.studies[0].match_evidence[1],
1045 ahri_tre_protocol::study::BrowserStudyMatchEvidence::Dataset { .. }
1046 ));
1047 assert!(matches!(
1048 response.studies[0].match_evidence[2],
1049 ahri_tre_protocol::study::BrowserStudyMatchEvidence::Datafile { .. }
1050 ));
1051 }
1052
1053 #[test]
1054 fn aggregate_search_bounds_evidence_and_excludes_private_source_fields() {
1055 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1056 let mut source = source(datastore_id);
1057 let template = source.datasets[0].clone();
1058 for offset in 1_u128..7 {
1059 let mut dataset = template.clone();
1060 dataset.asset.asset_id = AssetId(Uuid::from_u128(200 + offset));
1061 dataset.asset.name = format!("Sentinel dataset {offset}");
1062 dataset.versions[0].version.asset_id = dataset.asset.asset_id;
1063 dataset.versions[0].version.version_id = VersionId(Uuid::from_u128(220 + offset));
1064 dataset.versions[0].dataset.dataset_id = dataset.versions[0].version.version_id;
1065 source.datasets.push(dataset);
1066 }
1067 let mut request = BrowserStudySearchInput {
1068 session: None,
1069 predicates: None,
1070 query: Some(BrowserStudySearchQuery {
1071 text: Some(BrowserStudyTextQuery {
1072 mode: ahri_tre_protocol::search::TextMode::Contains,
1073 value: "sentinel".to_string(),
1074 }),
1075 facets: BrowserStudyFacetGroups::default(),
1076 }),
1077 page: PageRequest {
1078 cursor: None,
1079 limit: 10,
1080 },
1081 };
1082 request
1083 .validate(datastore_id, &ProtocolVersion::current())
1084 .unwrap();
1085
1086 let response = browser_study_search_response(
1087 datastore_id,
1088 vec![source],
1089 &request,
1090 &ProtocolVersion::current(),
1091 )
1092 .unwrap();
1093 let result = &response.studies[0];
1094 assert_eq!(
1095 result.match_evidence.len(),
1096 ahri_tre_protocol::study::MAX_BROWSER_STUDY_MATCH_EVIDENCE
1097 );
1098 assert!(result.match_evidence_truncated);
1099 let public_json = serde_json::to_string(&response).unwrap();
1100 assert!(!public_json.contains("never disclose me"));
1101 assert!(!public_json.contains("agent_instructions"));
1102 }
1103
1104 #[test]
1105 fn aggregate_search_keeps_dataset_and_datafile_version_evidence_separate() {
1106 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1107 let mut source = source(datastore_id);
1108 source.study.name = "Cohort".to_string();
1109 source.study.description = None;
1110 source.datasets[0].asset.name = "Dataset".to_string();
1111 let mut historical = source.datasets[0].versions[0].clone();
1112 historical.version.version_id = VersionId(Uuid::from_u128(199));
1113 historical.version.version_label = Some("historical needle".to_string());
1114 historical.version.is_latest = Some(false);
1115 historical.dataset.dataset_id = historical.version.version_id;
1116 source.datasets[0].versions[0].version.version_label = Some("current".to_string());
1117 source.datasets[0].versions[0].version.doi = Some("needle dataset DOI".to_string());
1118 source.datasets[0].versions.insert(0, historical);
1119 source.datafiles[0].asset.name = "Datafile".to_string();
1120 source.datafiles[0].versions[0].version.version_label = Some("current".to_string());
1121 source.datafiles[0].versions[0].datafile.format = "needle/format".to_string();
1122 let mut historical = source.datafiles[0].versions[0].clone();
1123 historical.version.version_id = VersionId(Uuid::from_u128(299));
1124 historical.version.version_note = Some("historical needle note".to_string());
1125 historical.version.doi = None;
1126 historical.version.is_latest = Some(false);
1127 historical.datafile.datafile_id = historical.version.version_id;
1128 historical.datafile.format = "text/csv".to_string();
1129 source.datafiles[0].versions.insert(0, historical);
1130 let mut request = search_request("needle");
1131 request
1132 .validate(datastore_id, &ProtocolVersion::current())
1133 .unwrap();
1134
1135 let response = browser_study_search_response(
1136 datastore_id,
1137 vec![source],
1138 &request,
1139 &ProtocolVersion::current(),
1140 )
1141 .unwrap();
1142
1143 let evidence = &response.studies[0].match_evidence;
1144 assert_eq!(evidence.len(), 4);
1145 assert!(evidence.iter().any(|item| matches!(
1146 item,
1147 BrowserStudyMatchEvidence::Dataset { version, matched_fields, .. }
1148 if version.id == PublicUuid::from_uuid(Uuid::from_u128(199))
1149 && matched_fields == &[BrowserStudyMatchedField::VersionLabel]
1150 )));
1151 assert!(evidence.iter().any(|item| matches!(
1152 item,
1153 BrowserStudyMatchEvidence::Dataset { version, matched_fields, .. }
1154 if version.id == PublicUuid::from_uuid(Uuid::from_u128(201))
1155 && matched_fields == &[BrowserStudyMatchedField::Doi]
1156 )));
1157 assert!(evidence.iter().any(|item| matches!(
1158 item,
1159 BrowserStudyMatchEvidence::Datafile { version, matched_fields, .. }
1160 if version.id == PublicUuid::from_uuid(Uuid::from_u128(299))
1161 && matched_fields == &[BrowserStudyMatchedField::VersionNote]
1162 )));
1163 assert!(evidence.iter().any(|item| matches!(
1164 item,
1165 BrowserStudyMatchEvidence::Datafile { version, matched_fields, .. }
1166 if version.id == PublicUuid::from_uuid(Uuid::from_u128(301))
1167 && matched_fields == &[BrowserStudyMatchedField::Format]
1168 )));
1169 }
1170
1171 #[test]
1172 fn aggregate_search_attributes_asset_evidence_to_latest_available_dataset_version() {
1173 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1174 let mut source = source(datastore_id);
1175 source.study.name = "Cohort".to_string();
1176 source.study.description = None;
1177 source.datafiles.clear();
1178
1179 let mut available = source.datasets[0].versions[0].clone();
1180 available.version.version_id = VersionId(Uuid::from_u128(199));
1181 available.version.is_latest = Some(false);
1182 available.dataset.dataset_id = available.version.version_id;
1183 source.datasets[0].versions[0].withdrawn_at = Some(chrono::Utc::now());
1184 source.datasets[0].versions.insert(0, available);
1185
1186 let request = search_request("sentinel dataset");
1187 let response = browser_study_search_response(
1188 datastore_id,
1189 vec![source],
1190 &request,
1191 &ProtocolVersion::current(),
1192 )
1193 .unwrap();
1194
1195 assert!(matches!(
1196 response.studies[0].match_evidence.as_slice(),
1197 [BrowserStudyMatchEvidence::Dataset {
1198 version,
1199 matched_fields,
1200 ..
1201 }] if version.id == PublicUuid::from_uuid(Uuid::from_u128(199))
1202 && matched_fields == &[BrowserStudyMatchedField::Name]
1203 ));
1204 }
1205
1206 #[test]
1207 fn predicate_search_keeps_existing_study_only_behavior() {
1208 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1209 let mut request: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
1210 "predicates": {
1211 "kind": "all_of",
1212 "predicates": [{
1213 "kind": "text",
1214 "field": "name",
1215 "mode": "contains",
1216 "value": "cohort"
1217 }]
1218 },
1219 "page": {"limit": 10}
1220 }))
1221 .unwrap();
1222 request
1223 .validate(datastore_id, &ProtocolVersion::current())
1224 .unwrap();
1225
1226 let response = browser_study_search_response(
1227 datastore_id,
1228 vec![source(datastore_id)],
1229 &request,
1230 &ProtocolVersion::current(),
1231 )
1232 .unwrap();
1233
1234 assert_eq!(response.returned_count, 1);
1235 assert_eq!(response.predicates, request.predicates);
1236 assert!(response.query.is_none());
1237 }
1238
1239 fn search_request(value: &str) -> BrowserStudySearchInput {
1240 BrowserStudySearchInput {
1241 session: None,
1242 predicates: None,
1243 query: Some(BrowserStudySearchQuery {
1244 text: Some(BrowserStudyTextQuery {
1245 mode: ahri_tre_protocol::search::TextMode::Contains,
1246 value: value.to_string(),
1247 }),
1248 facets: BrowserStudyFacetGroups::default(),
1249 }),
1250 page: PageRequest {
1251 cursor: None,
1252 limit: 10,
1253 },
1254 }
1255 }
1256
1257 fn source(datastore_id: PublicUuid) -> BrowserResourceDiscoverySource {
1258 let study_id = StudyId(Uuid::from_u128(100));
1259 let dataset_asset_id = AssetId(Uuid::from_u128(200));
1260 let dataset_version_id = VersionId(Uuid::from_u128(201));
1261 let datafile_asset_id = AssetId(Uuid::from_u128(300));
1262 let datafile_version_id = VersionId(Uuid::from_u128(301));
1263 BrowserResourceDiscoverySource {
1264 datastore_id,
1265 study: ahri_tre_pgmeta::PgStudyReadModel {
1266 study_id,
1267 name: "Sentinel cohort".to_string(),
1268 description: Some("A sentinel Study".to_string()),
1269 documentation: None,
1270 agent_instructions: Some("never disclose me".to_string()),
1271 external_id: None,
1272 study_type_id: None,
1273 date_created: None,
1274 created_by: None,
1275 },
1276 study_tags: vec![tag(1, "priority")],
1277 study_access_conditions: Vec::new(),
1278 domains: vec![ahri_tre_pgmeta::PgDomainReadModel {
1279 domain_id: DomainId(7),
1280 name: "demography".to_string(),
1281 uri: None,
1282 description: None,
1283 }],
1284 datasets: vec![BrowserDatasetDiscoverySourceRecord {
1285 asset: asset(
1286 dataset_asset_id,
1287 study_id,
1288 "Sentinel dataset",
1289 AssetType::Dataset,
1290 ),
1291 tags: vec![tag(2, "curated")],
1292 versions: vec![BrowserDatasetVersionDiscoverySourceRecord {
1293 version: version(dataset_version_id, dataset_asset_id),
1294 dataset: ahri_tre_pgmeta::PgDatasetReadModel {
1295 dataset_id: dataset_version_id,
1296 },
1297 withdrawn_at: None,
1298 tags: Vec::new(),
1299 access_conditions: Vec::new(),
1300 }],
1301 }],
1302 datafiles: vec![BrowserDataFileDiscoverySourceRecord {
1303 asset: asset(datafile_asset_id, study_id, "sentinel.csv", AssetType::File),
1304 tags: Vec::new(),
1305 versions: vec![BrowserDataFileVersionDiscoverySourceRecord {
1306 version: version(datafile_version_id, datafile_asset_id),
1307 datafile: BrowserDataFileMetadataSource {
1308 datafile_id: datafile_version_id,
1309 format: "text/csv".to_string(),
1310 size_bytes: None,
1311 compressed: None,
1312 encrypted: None,
1313 },
1314 tags: vec![tag(2, "curated")],
1315 access_conditions: Vec::new(),
1316 }],
1317 }],
1318 }
1319 }
1320
1321 fn asset(
1322 asset_id: AssetId,
1323 study_id: StudyId,
1324 name: &str,
1325 asset_type: AssetType,
1326 ) -> ahri_tre_pgmeta::PgAssetReadModel {
1327 ahri_tre_pgmeta::PgAssetReadModel {
1328 asset_id,
1329 study_id,
1330 name: name.to_string(),
1331 description: None,
1332 agent_instructions: Some("never disclose me".to_string()),
1333 asset_type,
1334 date_created: None,
1335 created_by: None,
1336 }
1337 }
1338
1339 fn version(
1340 version_id: VersionId,
1341 asset_id: AssetId,
1342 ) -> ahri_tre_pgmeta::PgAssetVersionReadModel {
1343 ahri_tre_pgmeta::PgAssetVersionReadModel {
1344 version_id,
1345 asset_id,
1346 major: 1,
1347 minor: 0,
1348 patch: 0,
1349 version_label: Some("sentinel release".to_string()),
1350 version_note: None,
1351 is_latest: Some(true),
1352 doi: None,
1353 created_at: None,
1354 created_by: None,
1355 }
1356 }
1357
1358 fn tag(tag_id: i64, name: &str) -> ahri_tre_pgmeta::PgTagReadModel {
1359 ahri_tre_pgmeta::PgTagReadModel {
1360 tag_id,
1361 name: name.to_string(),
1362 }
1363 }
1364}