1use crate::{
8 browser_catalogue_search::ordered_page,
9 browser_resource_discovery::{
10 BrowserDataFileDiscoverySourceRecord, BrowserResourceDiscoveryCapability,
11 BrowserResourceDiscoveryError, BrowserResourceDiscoverySource, datafile_record,
12 },
13};
14use thiserror::Error;
15
16#[derive(Debug, Error, Clone, PartialEq, Eq)]
17pub enum BrowserDataFileWorkflowError {
18 #[error(transparent)]
19 Discovery(#[from] BrowserResourceDiscoveryError),
20 #[error("browser Datafile search request is invalid")]
21 InvalidRequest(ahri_tre_protocol::ProtocolError),
22}
23
24#[allow(clippy::result_large_err)]
25pub fn search_browser_datafiles(
26 capability: &dyn BrowserResourceDiscoveryCapability,
27 datastore_id: PublicUuid,
28 request: &SearchDataFilesRequest,
29 protocol_version: &ProtocolVersion,
30) -> Result<BrowserDataFileSearchResponse, BrowserDataFileWorkflowError> {
31 validate_references(datastore_id, request, protocol_version)
32 .map_err(BrowserDataFileWorkflowError::InvalidRequest)?;
33 let sources = capability.discover_catalogue_search_resources(datastore_id)?;
34 browser_datafile_search_response(datastore_id, sources, request, protocol_version)
35 .map_err(BrowserDataFileWorkflowError::InvalidRequest)
36}
37use ahri_tre_protocol::{
38 ProtocolVersion, PublicUuid,
39 datafile::{
40 BrowserDataFileSearchCursorAnchor, BrowserDataFileSearchEmptyState,
41 BrowserDataFileSearchResponse, DataFilePredicate, DataFileTextField,
42 SearchDataFilesRequest,
43 },
44 refs::{StudyRef, TagScope},
45 search::{PredicateSet, TextMode},
46 study::{BrowserSearchOrdering, StudySelector},
47};
48
49#[allow(clippy::result_large_err)]
50fn validate_references(
51 datastore_id: PublicUuid,
52 request: &SearchDataFilesRequest,
53 protocol_version: &ProtocolVersion,
54) -> Result<(), ahri_tre_protocol::ProtocolError> {
55 for predicate in request.predicates.predicates() {
56 match predicate {
57 DataFilePredicate::Study { study } => {
58 crate::browser_catalogue_search::validate_study(datastore_id, study)?
59 }
60 DataFilePredicate::Tag(tag) => {
61 if let ahri_tre_protocol::refs::TagSelector::Id { tag } = tag.selector {
62 tag.validate_context(datastore_id, ahri_tre_protocol::refs::ObjectKind::Tag)?;
63 }
64 }
65 _ => {}
66 }
67 }
68 if let Some(cursor) = request.browser_decoded_cursor(datastore_id, protocol_version)? {
69 use ahri_tre_protocol::refs::ObjectKind;
70 cursor
71 .study
72 .validate_context(datastore_id, ObjectKind::Study)?;
73 cursor
74 .datafile
75 .validate_context(datastore_id, ObjectKind::Asset)?;
76 cursor
77 .asset
78 .validate_context(datastore_id, ObjectKind::Asset)?;
79 }
80 Ok(())
81}
82
83#[derive(Debug, Clone)]
84struct DataFileCandidate {
85 study: StudyRef,
86 domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
87 record: BrowserDataFileDiscoverySourceRecord,
88}
89
90#[allow(clippy::result_large_err)]
91pub fn browser_datafile_search_response(
92 datastore_id: PublicUuid,
93 sources: Vec<BrowserResourceDiscoverySource>,
94 request: &SearchDataFilesRequest,
95 protocol_version: &ProtocolVersion,
96) -> Result<BrowserDataFileSearchResponse, ahri_tre_protocol::ProtocolError> {
97 validate_references(datastore_id, request, protocol_version)?;
98 let after = request.browser_decoded_cursor(datastore_id, protocol_version)?;
99 let datafiles = sources
100 .into_iter()
101 .flat_map(|source| {
102 let study = StudyRef {
103 datastore_id,
104 kind: ahri_tre_protocol::refs::ObjectKind::Study,
105 id: PublicUuid::from_uuid(source.study.study_id.0),
106 };
107 let domains = source.domains.clone();
108 source.datafiles.into_iter().filter_map(move |record| {
109 datafile_matches(&source.study, &domains, &record, request).then(|| {
110 DataFileCandidate {
111 study,
112 domains: domains.clone(),
113 record,
114 }
115 })
116 })
117 })
118 .collect::<Vec<_>>();
119 let limit = usize::from(request.page.limit);
120 let page = ordered_page(
121 datafiles,
122 after.as_ref().map(cursor_sort_key),
123 limit,
124 datafile_sort_key,
125 );
126 let next_cursor = if page.has_more {
127 page.items
128 .last()
129 .map(|candidate| {
130 request.encode_browser_cursor(
131 datastore_id,
132 protocol_version,
133 datafile_cursor_anchor(datastore_id, candidate),
134 )
135 })
136 .transpose()?
137 } else {
138 None
139 };
140 let returned_count = u16::try_from(page.items.len()).expect("page length fits u16");
141 let empty_state = page
142 .items
143 .is_empty()
144 .then(|| BrowserDataFileSearchEmptyState {
145 code: "no_matching_browser_visible_datafiles".to_string(),
146 message: "No browser-visible datafiles match the active search filters.".to_string(),
147 });
148 Ok(BrowserDataFileSearchResponse {
149 protocol_version: protocol_version.clone(),
150 datastore_id,
151 family: "datafiles".to_string(),
152 predicates: request.predicates.clone(),
153 datafiles: page
154 .items
155 .into_iter()
156 .map(|candidate| {
157 datafile_record(
158 datastore_id,
159 candidate.study,
160 &candidate
161 .domains
162 .into_iter()
163 .map(|value| domain_summary(datastore_id, value))
164 .collect::<Vec<_>>(),
165 candidate.record,
166 )
167 })
168 .collect(),
169 returned_count,
170 requested_limit: request.page.limit,
171 ordering: BrowserSearchOrdering {
172 id: "datafile_name_then_study_identity_then_datafile_identity".to_string(),
173 description:
174 "Datafiles are ordered by canonical name, Study identity, then datafile identity."
175 .to_string(),
176 },
177 has_more: page.has_more,
178 next_cursor,
179 empty_state,
180 })
181}
182
183fn datafile_matches(
184 study: &ahri_tre_pgmeta::PgStudyReadModel,
185 domains: &[ahri_tre_pgmeta::PgDomainReadModel],
186 record: &BrowserDataFileDiscoverySourceRecord,
187 request: &SearchDataFilesRequest,
188) -> bool {
189 let matches = |predicate: &DataFilePredicate| match predicate {
190 DataFilePredicate::Text(text) => match text.field {
191 DataFileTextField::Name => text_matches(&record.asset.name, text.mode, &text.value),
192 DataFileTextField::Description => record
193 .asset
194 .description
195 .as_deref()
196 .is_some_and(|value| text_matches(value, text.mode, &text.value)),
197 DataFileTextField::Format => text_matches(
198 &latest_version(record).datafile.format,
199 text.mode,
200 &text.value,
201 ),
202 },
203 DataFilePredicate::Study { study: selector } => match selector {
204 StudySelector::Id { study: expected } => {
205 expected.id == PublicUuid::from_uuid(study.study_id.0)
206 }
207 StudySelector::Name { domain, name } => {
208 study.name == name.as_str()
209 && domain.as_ref().is_none_or(|domain| {
210 domains
211 .iter()
212 .any(|candidate| domain_matches(candidate, domain))
213 })
214 }
215 },
216 DataFilePredicate::Format { format } => latest_version(record).datafile.format == *format,
217 DataFilePredicate::Tag(tag) => {
218 latest_version(record)
219 .tags
220 .iter()
221 .any(|candidate| match &tag.selector {
222 ahri_tre_protocol::refs::TagSelector::Id { tag } => {
223 ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", candidate.tag_id)
224 == tag.id
225 }
226 ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
227 *scope == TagScope::Global && candidate.name == label.as_str()
228 }
229 })
230 }
231 };
232 match &request.predicates {
233 PredicateSet::AllOf(predicates) => predicates.iter().all(matches),
234 PredicateSet::AnyOf(predicates) => predicates.iter().any(matches),
235 }
236}
237
238fn text_matches(value: &str, mode: TextMode, expected: &str) -> bool {
239 ahri_tre_core::search_text_matches(
240 value,
241 match mode {
242 TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
243 TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
244 TextMode::Contains => ahri_tre_core::SearchTextMode::Contains,
245 },
246 expected,
247 )
248}
249
250fn domain_matches(
251 candidate: &ahri_tre_pgmeta::PgDomainReadModel,
252 selector: &ahri_tre_protocol::domain::DomainSelector,
253) -> bool {
254 match selector {
255 ahri_tre_protocol::domain::DomainSelector::Id { domain } => {
256 ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", candidate.domain_id.0)
257 == domain.id
258 }
259 ahri_tre_protocol::domain::DomainSelector::Name { name } => candidate.name == name.as_str(),
260 }
261}
262
263fn datafile_sort_key(candidate: &DataFileCandidate) -> (String, String, uuid::Uuid, uuid::Uuid) {
264 let record = &candidate.record;
265 (
266 record.asset.name.to_ascii_lowercase(),
267 record.asset.name.clone(),
268 candidate.study.id.as_uuid(),
269 record.asset.asset_id.0,
270 )
271}
272
273fn latest_version(
274 record: &BrowserDataFileDiscoverySourceRecord,
275) -> &crate::browser_resource_discovery::BrowserDataFileVersionDiscoverySourceRecord {
276 record
277 .versions
278 .iter()
279 .find(|version| version.version.is_latest.unwrap_or(false))
280 .unwrap_or_else(|| record.versions.last().expect("datafile has versions"))
281}
282
283fn cursor_sort_key(
284 anchor: &BrowserDataFileSearchCursorAnchor,
285) -> (String, String, uuid::Uuid, uuid::Uuid) {
286 (
287 anchor.name.to_ascii_lowercase(),
288 anchor.name.clone(),
289 anchor.study.id.as_uuid(),
290 anchor.asset.id.as_uuid(),
291 )
292}
293
294fn datafile_cursor_anchor(
295 datastore_id: PublicUuid,
296 candidate: &DataFileCandidate,
297) -> BrowserDataFileSearchCursorAnchor {
298 let record = &candidate.record;
299 BrowserDataFileSearchCursorAnchor {
300 study: candidate.study,
301 datafile: ahri_tre_protocol::refs::DataFileRef {
302 datastore_id,
303 kind: ahri_tre_protocol::refs::ObjectKind::Asset,
304 id: PublicUuid::from_uuid(latest_version(record).datafile.datafile_id.0),
305 },
306 asset: ahri_tre_protocol::refs::AssetRef {
307 datastore_id,
308 kind: ahri_tre_protocol::refs::ObjectKind::Asset,
309 id: PublicUuid::from_uuid(record.asset.asset_id.0),
310 },
311 name: record.asset.name.clone(),
312 }
313}
314
315fn domain_summary(
316 datastore_id: PublicUuid,
317 domain: ahri_tre_pgmeta::PgDomainReadModel,
318) -> ahri_tre_protocol::domain::DomainSummary {
319 ahri_tre_protocol::domain::DomainSummary {
320 domain: ahri_tre_protocol::refs::DomainRef {
321 datastore_id,
322 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
323 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", domain.domain_id.0),
324 },
325 name: domain.name,
326 description: domain.description,
327 uri: domain.uri,
328 }
329}