Skip to main content

ahri_tre_app/
browser_datafile_search.rs

1//! Browser-safe, datastore-scoped Datafile catalogue search.
2//!
3//! This module reuses the resource Discovery source and projection. It never
4//! reads file content and keeps the browser response distinct from daemon
5//! Datafile search envelopes.
6
7use 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}