Skip to main content

helios_persistence/core/
search.rs

1//! Search provider traits.
2//!
3//! This module defines a hierarchy of search provider traits:
4//! - [`SearchProvider`] - Basic single-type search
5//! - [`MultiTypeSearchProvider`] - Search across multiple resource types
6//! - [`IncludeProvider`] - Support for _include
7//! - [`RevincludeProvider`] - Support for _revinclude
8//! - [`ChainedSearchProvider`] - Chained parameters and _has
9//! - [`TerminologySearchProvider`] - :above, :below, :in, :not-in
10//! - [`TextSearchProvider`] - Full-text search (_text, _content, :text)
11
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use parking_lot::RwLock;
17
18use crate::error::StorageResult;
19use crate::search::{IndexValue, SearchParameterExtractor, SearchParameterRegistry};
20use crate::tenant::TenantContext;
21use crate::types::{
22    IncludeDirective, IncludeType, Page, ReverseChainedParameter, SearchBundle, SearchParamType,
23    SearchParameter, SearchQuery, SearchValue, StoredResource,
24};
25
26use super::storage::ResourceStorage;
27
28/// Result of a search operation.
29#[derive(Debug, Clone)]
30pub struct SearchResult {
31    /// The matching resources.
32    pub resources: Page<StoredResource>,
33
34    /// Included resources (from _include/_revinclude).
35    pub included: Vec<StoredResource>,
36
37    /// Total count of matches (if requested via _total).
38    pub total: Option<u64>,
39
40    /// Relevance scores (`Bundle.entry.search.score`) for matched resources,
41    /// keyed by resource URL (`Type/id`). Populated by backends that compute
42    /// relevance (e.g. Elasticsearch full-text search); empty otherwise.
43    pub scores: HashMap<String, f64>,
44}
45
46impl SearchResult {
47    /// Creates a new search result.
48    pub fn new(resources: Page<StoredResource>) -> Self {
49        Self {
50            resources,
51            included: Vec::new(),
52            total: None,
53            scores: HashMap::new(),
54        }
55    }
56
57    /// Adds included resources.
58    pub fn with_included(mut self, included: Vec<StoredResource>) -> Self {
59        self.included = included;
60        self
61    }
62
63    /// Sets the total count.
64    pub fn with_total(mut self, total: u64) -> Self {
65        self.total = Some(total);
66        self
67    }
68
69    /// Sets the relevance scores, keyed by resource URL (`Type/id`).
70    pub fn with_scores(mut self, scores: HashMap<String, f64>) -> Self {
71        self.scores = scores;
72        self
73    }
74
75    /// Returns the number of matching resources in this page.
76    pub fn len(&self) -> usize {
77        self.resources.len()
78    }
79
80    /// Returns true if there are no matching resources.
81    pub fn is_empty(&self) -> bool {
82        self.resources.is_empty()
83    }
84
85    /// Returns the cursor for the next page, if there is one.
86    pub fn next_cursor(&self) -> Option<&String> {
87        self.resources.page_info.next_cursor.as_ref()
88    }
89
90    /// Returns the cursor for the previous page, if there is one.
91    pub fn previous_cursor(&self) -> Option<&String> {
92        self.resources.page_info.previous_cursor.as_ref()
93    }
94
95    /// Returns whether there are more results after this page.
96    pub fn has_next(&self) -> bool {
97        self.resources.page_info.has_next
98    }
99
100    /// Returns whether there are results before this page.
101    pub fn has_previous(&self) -> bool {
102        self.resources.page_info.has_previous
103    }
104
105    /// Converts the result to a FHIR SearchBundle.
106    pub fn to_bundle(&self, base_url: &str, self_link: &str) -> SearchBundle {
107        use crate::types::{BundleEntry, SearchBundle};
108
109        let mut bundle = SearchBundle::new().with_self_link(self_link);
110
111        if let Some(total) = self.total {
112            bundle = bundle.with_total(total);
113        }
114
115        // Add next link if there's more data. The self_link already contains
116        // the request's query string (potentially including a `_cursor` param
117        // from the previous page), so we strip any existing `_cursor=` and
118        // append the new one with the correct delimiter.
119        if let Some(ref cursor) = self.resources.page_info.next_cursor {
120            bundle = bundle.with_next_link(replace_cursor_param(self_link, cursor));
121        }
122
123        if let Some(ref cursor) = self.resources.page_info.previous_cursor {
124            bundle = bundle.with_previous_link(replace_cursor_param(self_link, cursor));
125        }
126
127        // First-page link: the self URL with paging params (`_cursor` / `_offset`)
128        // stripped. Emitted only for multi-page results (when a next/previous page
129        // exists). A `last` link is intentionally not emitted: under keyset
130        // (cursor) paging the final page is not cheaply computable.
131        if self.resources.page_info.next_cursor.is_some()
132            || self.resources.page_info.previous_cursor.is_some()
133        {
134            bundle = bundle.with_link("first", strip_paging_params(self_link));
135        }
136
137        // Add matching resources, attaching a relevance score when the backend
138        // computed one for this resource (`Bundle.entry.search.score`).
139        for resource in &self.resources.items {
140            let full_url = format!("{}/{}", base_url, resource.url());
141            let entry = BundleEntry::match_entry(full_url, resource.content().clone())
142                .with_score(self.scores.get(&resource.url()).copied());
143            bundle = bundle.with_entry(entry);
144        }
145
146        // Add included resources
147        for resource in &self.included {
148            let full_url = format!("{}/{}", base_url, resource.url());
149            bundle = bundle.with_entry(BundleEntry::include_entry(
150                full_url,
151                resource.content().clone(),
152            ));
153        }
154
155        bundle
156    }
157}
158
159/// Returns `url` with any existing `_cursor=…` query parameter replaced by the
160/// supplied opaque `cursor` value. Used to build pagination links from the
161/// request's self URL.
162///
163/// Cursors are base64-url-safe so they don't need percent-encoding; the only
164/// surgery required is splitting on the first `?`, dropping any pre-existing
165/// `_cursor` pair, and re-joining with `&`. This is what makes the difference
166/// between
167///
168/// ```text
169/// .../Patient?_count=3&_elements=id?_cursor=…   // wrong: literal `?` mid-query
170/// ```
171///
172/// and the spec-compliant
173///
174/// ```text
175/// .../Patient?_count=3&_elements=id&_cursor=…
176/// ```
177/// Returns `url` with any `_cursor=…` and `_offset=…` query parameters removed,
178/// yielding the first-page URL for a paginated search.
179fn strip_paging_params(url: &str) -> String {
180    let (base, query) = match url.find('?') {
181        Some(pos) => (&url[..pos], &url[pos + 1..]),
182        None => return url.to_string(),
183    };
184
185    let parts: Vec<String> = query
186        .split('&')
187        .filter(|p| !p.is_empty() && !p.starts_with("_cursor=") && !p.starts_with("_offset="))
188        .map(str::to_string)
189        .collect();
190
191    if parts.is_empty() {
192        base.to_string()
193    } else {
194        format!("{}?{}", base, parts.join("&"))
195    }
196}
197
198fn replace_cursor_param(url: &str, cursor: &str) -> String {
199    let (base, query) = match url.find('?') {
200        Some(pos) => (&url[..pos], &url[pos + 1..]),
201        None => (url, ""),
202    };
203
204    let mut parts: Vec<String> = query
205        .split('&')
206        .filter(|p| !p.is_empty() && !p.starts_with("_cursor="))
207        .map(str::to_string)
208        .collect();
209    parts.push(format!("_cursor={}", cursor));
210
211    format!("{}?{}", base, parts.join("&"))
212}
213
214/// Basic search provider for single resource type queries.
215///
216/// This trait provides search functionality for a single resource type,
217/// corresponding to the FHIR search interaction:
218/// `GET [base]/[type]?[parameters]`
219///
220/// # Example
221///
222/// ```ignore
223/// use helios_persistence::core::SearchProvider;
224/// use helios_persistence::types::{SearchQuery, SearchParameter, SearchParamType, SearchValue};
225///
226/// async fn search_patients<S: SearchProvider>(
227///     storage: &S,
228///     tenant: &TenantContext,
229/// ) -> Result<(), StorageError> {
230///     let query = SearchQuery::new("Patient")
231///         .with_parameter(SearchParameter {
232///             name: "name".to_string(),
233///             param_type: SearchParamType::String,
234///             modifier: None,
235///             values: vec![SearchValue::eq("Smith")],
236///             chain: vec![],
237///             components: vec![],
238///         })
239///         .with_count(20);
240///
241///     let result = storage.search(tenant, &query).await?;
242///
243///     for resource in result.resources.items {
244///         println!("Found: {}", resource.url());
245///     }
246///
247///     Ok(())
248/// }
249/// ```
250#[async_trait]
251pub trait SearchProvider: ResourceStorage {
252    /// Searches for resources matching the query.
253    ///
254    /// # Arguments
255    ///
256    /// * `tenant` - The tenant context for this operation
257    /// * `query` - The search query with parameters
258    ///
259    /// # Returns
260    ///
261    /// A search result with matching resources and pagination info.
262    ///
263    /// # Errors
264    ///
265    /// * `StorageError::Validation` - If the query contains invalid parameters
266    /// * `StorageError::Search` - If a search feature is not supported
267    /// * `StorageError::Tenant` - If the tenant doesn't have search permission
268    async fn search(
269        &self,
270        tenant: &TenantContext,
271        query: &SearchQuery,
272    ) -> StorageResult<SearchResult>;
273
274    /// Counts resources matching the query without returning them.
275    ///
276    /// This is more efficient than search when you only need the count.
277    async fn search_count(&self, tenant: &TenantContext, query: &SearchQuery)
278    -> StorageResult<u64>;
279
280    /// Returns the backend's search parameter registry.
281    ///
282    /// The registry is the single source of truth for search parameter type
283    /// resolution (see [`crate::search::resolve_param_type`]). REST extractors
284    /// and chained-search builders both consult it so they cannot disagree on
285    /// whether a given param is a Date vs. Token vs. Reference, etc.
286    fn search_param_registry(&self) -> &Arc<RwLock<SearchParameterRegistry>>;
287
288    /// Whether this backend can evaluate `_contained=true|both` searches (which
289    /// require contained-resource indexing). Defaults to `false`; backends that
290    /// index `contained[]` entries override this. The REST layer uses it to
291    /// reject `_contained` with `501` on backends that don't support it rather
292    /// than silently returning an unfiltered result.
293    fn supports_contained_search(&self) -> bool {
294        false
295    }
296
297    /// Returns the search modifiers this backend actually honors for a given
298    /// parameter type (e.g. `exact`, `contains` for strings; `not`, `in` for
299    /// tokens). Used by the REST layer to advertise supported modifiers in the
300    /// CapabilityStatement.
301    ///
302    /// Defaults to an empty list (advertise nothing); real search backends
303    /// override this to reflect what their search implementation accepts so the
304    /// CapabilityStatement stays honest.
305    fn modifiers_for_param_type(&self, param_type: SearchParamType) -> Vec<&'static str> {
306        let _ = param_type;
307        Vec::new()
308    }
309}
310
311/// Search provider that supports searching across multiple resource types.
312///
313/// This extends [`SearchProvider`] to support system-level search:
314/// `GET [base]?[parameters]`
315#[async_trait]
316pub trait MultiTypeSearchProvider: SearchProvider {
317    /// Searches across multiple resource types.
318    ///
319    /// # Arguments
320    ///
321    /// * `tenant` - The tenant context for this operation
322    /// * `resource_types` - The resource types to search (empty = all types)
323    /// * `query` - The search query
324    ///
325    /// # Returns
326    ///
327    /// A search result with matching resources from all specified types.
328    async fn search_multi(
329        &self,
330        tenant: &TenantContext,
331        resource_types: &[&str],
332        query: &SearchQuery,
333    ) -> StorageResult<SearchResult>;
334}
335
336/// Search provider that supports _include.
337///
338/// _include adds referenced resources to the search results.
339#[async_trait]
340pub trait IncludeProvider: SearchProvider {
341    /// Resolves _include directives for search results.
342    ///
343    /// # Arguments
344    ///
345    /// * `tenant` - The tenant context for this operation
346    /// * `resources` - The primary search results
347    /// * `includes` - The include directives to resolve
348    ///
349    /// # Returns
350    ///
351    /// Resources referenced by the primary results according to the include directives.
352    async fn resolve_includes(
353        &self,
354        tenant: &TenantContext,
355        resources: &[StoredResource],
356        includes: &[IncludeDirective],
357    ) -> StorageResult<Vec<StoredResource>>;
358}
359
360/// Search provider that supports _revinclude.
361///
362/// _revinclude adds resources that reference the search results.
363#[async_trait]
364pub trait RevincludeProvider: SearchProvider {
365    /// Resolves _revinclude directives for search results.
366    ///
367    /// # Arguments
368    ///
369    /// * `tenant` - The tenant context for this operation
370    /// * `resources` - The primary search results
371    /// * `revincludes` - The revinclude directives to resolve
372    ///
373    /// # Returns
374    ///
375    /// Resources that reference the primary results according to the revinclude directives.
376    async fn resolve_revincludes(
377        &self,
378        tenant: &TenantContext,
379        resources: &[StoredResource],
380        revincludes: &[IncludeDirective],
381    ) -> StorageResult<Vec<StoredResource>>;
382}
383
384/// Maximum number of `:iterate` hops when transitively following includes,
385/// guarding against reference cycles.
386const MAX_INCLUDE_ITERATE_DEPTH: usize = 5;
387
388/// Upper bound on resources fetched per internal include/revinclude query, to
389/// avoid the default page size silently truncating included resources.
390const INCLUDE_FETCH_LIMIT: u32 = 10_000;
391
392/// Resolves `_include`/`_revinclude` directives for a set of primary matches,
393/// following `:iterate` directives transitively until no new resources are
394/// found (bounded by [`MAX_INCLUDE_ITERATE_DEPTH`]). Included resources are
395/// deduplicated by `type/id` and never include a primary match.
396///
397/// This is the single, backend-agnostic include-resolution path used by the
398/// REST layer for backends whose `search()` does not resolve includes inline
399/// (SQLite, Postgres). References are extracted via the search-parameter
400/// registry's FHIRPath expression — so parameters whose name differs from the
401/// JSON field (e.g. Patient `organization` → `managingOrganization`) resolve
402/// correctly — and the referenced resources are fetched with `search()`.
403pub async fn resolve_includes_iterative<S>(
404    provider: &S,
405    tenant: &TenantContext,
406    matches: &[StoredResource],
407    includes: &[IncludeDirective],
408) -> StorageResult<Vec<StoredResource>>
409where
410    S: SearchProvider + ?Sized,
411{
412    if matches.is_empty() || includes.is_empty() {
413        return Ok(Vec::new());
414    }
415
416    let extractor = SearchParameterExtractor::new(provider.search_param_registry().clone());
417    let key = |r: &StoredResource| format!("{}/{}", r.resource_type(), r.id());
418
419    // Don't re-include primary matches.
420    let mut seen: HashSet<String> = matches.iter().map(&key).collect();
421    let mut included: Vec<StoredResource> = Vec::new();
422
423    let mut frontier: Vec<StoredResource> = matches.to_vec();
424    let mut first_hop = true;
425    let mut depth = 0;
426
427    loop {
428        // First hop applies all directives; later hops only `:iterate` ones.
429        let active: Vec<&IncludeDirective> =
430            includes.iter().filter(|d| first_hop || d.iterate).collect();
431        if active.is_empty() {
432            break;
433        }
434
435        let mut fetched: Vec<StoredResource> = Vec::new();
436        for directive in active {
437            match directive.include_type {
438                IncludeType::Include => {
439                    // Forward: collect references from the frontier resources,
440                    // then fetch the referenced resources by id.
441                    let mut wanted: Vec<(String, String)> = Vec::new();
442                    for res in &frontier {
443                        if res.resource_type() != directive.source_type {
444                            continue;
445                        }
446                        let def = provider
447                            .search_param_registry()
448                            .read()
449                            .get_param(res.resource_type(), &directive.search_param);
450                        let Some(def) = def else { continue };
451                        if let Ok(values) = extractor.extract_for_param(res.content(), &def) {
452                            for v in values {
453                                if let IndexValue::Reference { reference, .. } = v.value {
454                                    if let Some((t, i)) = reference.split_once('/') {
455                                        if let Some(target) = &directive.target_type {
456                                            if t != target {
457                                                continue;
458                                            }
459                                        }
460                                        wanted.push((t.to_string(), i.to_string()));
461                                    }
462                                }
463                            }
464                        }
465                    }
466                    // Group ids by type and fetch each group.
467                    let mut by_type: std::collections::HashMap<String, Vec<String>> =
468                        std::collections::HashMap::new();
469                    for (t, i) in wanted {
470                        by_type.entry(t).or_default().push(i);
471                    }
472                    for (rtype, ids) in by_type {
473                        let mut q = SearchQuery::new(&rtype).with_parameter(SearchParameter {
474                            name: "_id".to_string(),
475                            param_type: SearchParamType::Token,
476                            modifier: None,
477                            values: ids.iter().map(SearchValue::eq).collect(),
478                            chain: vec![],
479                            components: vec![],
480                        });
481                        q.count = Some(INCLUDE_FETCH_LIMIT);
482                        let result = provider.search(tenant, &q).await?;
483                        fetched.extend(result.resources.items);
484                    }
485                }
486                IncludeType::Revinclude => {
487                    // Reverse: find source resources that reference any frontier
488                    // resource via the directive's reference parameter.
489                    let refs: Vec<SearchValue> =
490                        frontier.iter().map(|r| SearchValue::eq(key(r))).collect();
491                    if refs.is_empty() {
492                        continue;
493                    }
494                    let mut q =
495                        SearchQuery::new(&directive.source_type).with_parameter(SearchParameter {
496                            name: directive.search_param.clone(),
497                            param_type: SearchParamType::Reference,
498                            modifier: None,
499                            values: refs,
500                            chain: vec![],
501                            components: vec![],
502                        });
503                    q.count = Some(INCLUDE_FETCH_LIMIT);
504                    let result = provider.search(tenant, &q).await?;
505                    fetched.extend(result.resources.items);
506                }
507            }
508        }
509
510        // Dedup against everything seen so far; newly-added become next frontier.
511        let mut next = Vec::new();
512        for r in fetched {
513            if seen.insert(key(&r)) {
514                next.push(r.clone());
515                included.push(r);
516            }
517        }
518
519        first_hop = false;
520        depth += 1;
521        frontier = next;
522        if frontier.is_empty() || depth >= MAX_INCLUDE_ITERATE_DEPTH {
523            break;
524        }
525    }
526
527    Ok(included)
528}
529
530/// Search provider that supports chained parameters and _has.
531///
532/// Chained parameters search on referenced resources:
533/// `Observation?patient.name=Smith`
534///
535/// _has searches for resources referenced by other resources:
536/// `Patient?_has:Observation:patient:code=1234-5`
537#[async_trait]
538pub trait ChainedSearchProvider: SearchProvider {
539    /// Evaluates a chained search and returns matching resource IDs.
540    ///
541    /// This is used internally to resolve chains before the main search.
542    ///
543    /// # Arguments
544    ///
545    /// * `tenant` - The tenant context for this operation
546    /// * `base_type` - The base resource type being searched
547    /// * `chain` - The chain path (e.g., "patient.organization.name")
548    /// * `value` - The value to match
549    ///
550    /// # Returns
551    ///
552    /// IDs of base resources that match the chain condition.
553    async fn resolve_chain(
554        &self,
555        tenant: &TenantContext,
556        base_type: &str,
557        chain: &str,
558        value: &str,
559    ) -> StorageResult<Vec<String>>;
560
561    /// Evaluates a reverse chain (_has) and returns matching resource IDs.
562    ///
563    /// # Arguments
564    ///
565    /// * `tenant` - The tenant context for this operation
566    /// * `base_type` - The base resource type being searched
567    /// * `reverse_chain` - The reverse chain parameters
568    ///
569    /// # Returns
570    ///
571    /// IDs of base resources that are referenced by matching resources.
572    async fn resolve_reverse_chain(
573        &self,
574        tenant: &TenantContext,
575        base_type: &str,
576        reverse_chain: &ReverseChainedParameter,
577    ) -> StorageResult<Vec<String>>;
578}
579
580/// Search provider that supports terminology-aware modifiers.
581///
582/// These modifiers require integration with a terminology service:
583/// - `:above` - Match codes above in the hierarchy
584/// - `:below` - Match codes below in the hierarchy
585/// - `:in` - Match codes in a value set
586/// - `:not-in` - Match codes not in a value set
587#[async_trait]
588pub trait TerminologySearchProvider: SearchProvider {
589    /// Expands a value set and returns member codes.
590    ///
591    /// # Arguments
592    ///
593    /// * `value_set_url` - The canonical URL of the value set
594    ///
595    /// # Returns
596    ///
597    /// A list of (system, code) pairs in the value set.
598    async fn expand_value_set(&self, value_set_url: &str) -> StorageResult<Vec<(String, String)>>;
599
600    /// Gets codes above the given code in the hierarchy.
601    ///
602    /// # Arguments
603    ///
604    /// * `system` - The code system URL
605    /// * `code` - The code to find ancestors for
606    ///
607    /// # Returns
608    ///
609    /// Codes that are ancestors of the given code (including the code itself).
610    async fn codes_above(&self, system: &str, code: &str) -> StorageResult<Vec<String>>;
611
612    /// Gets codes below the given code in the hierarchy.
613    ///
614    /// # Arguments
615    ///
616    /// * `system` - The code system URL
617    /// * `code` - The code to find descendants for
618    ///
619    /// # Returns
620    ///
621    /// Codes that are descendants of the given code (including the code itself).
622    async fn codes_below(&self, system: &str, code: &str) -> StorageResult<Vec<String>>;
623}
624
625/// Search provider that supports full-text search.
626///
627/// Full-text search operations:
628/// - `_text` - Search in the narrative
629/// - `_content` - Search in the entire resource content
630/// - `:text` modifier - Full-text search on token parameters
631#[async_trait]
632pub trait TextSearchProvider: SearchProvider {
633    /// Performs a full-text search on resource narratives.
634    ///
635    /// # Arguments
636    ///
637    /// * `tenant` - The tenant context for this operation
638    /// * `resource_type` - The resource type to search
639    /// * `text` - The text to search for
640    /// * `pagination` - Pagination settings
641    ///
642    /// # Returns
643    ///
644    /// Resources with matching narrative text, ordered by relevance.
645    async fn search_text(
646        &self,
647        tenant: &TenantContext,
648        resource_type: &str,
649        text: &str,
650        pagination: &crate::types::Pagination,
651    ) -> StorageResult<SearchResult>;
652
653    /// Performs a full-text search on entire resource content.
654    ///
655    /// # Arguments
656    ///
657    /// * `tenant` - The tenant context for this operation
658    /// * `resource_type` - The resource type to search
659    /// * `content` - The content to search for
660    /// * `pagination` - Pagination settings
661    ///
662    /// # Returns
663    ///
664    /// Resources with matching content, ordered by relevance.
665    async fn search_content(
666        &self,
667        tenant: &TenantContext,
668        resource_type: &str,
669        content: &str,
670        pagination: &crate::types::Pagination,
671    ) -> StorageResult<SearchResult>;
672}
673
674/// Marker trait for search providers that support all advanced features.
675///
676/// This is a convenience trait that combines all search capabilities.
677pub trait FullSearchProvider:
678    SearchProvider
679    + MultiTypeSearchProvider
680    + IncludeProvider
681    + RevincludeProvider
682    + ChainedSearchProvider
683{
684}
685
686// Blanket implementation for types that implement all required traits
687impl<T> FullSearchProvider for T where
688    T: SearchProvider
689        + MultiTypeSearchProvider
690        + IncludeProvider
691        + RevincludeProvider
692        + ChainedSearchProvider
693{
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::types::PageInfo;
700    use helios_fhir::FhirVersion;
701
702    #[test]
703    fn test_search_result_creation() {
704        let page = Page::new(Vec::new(), PageInfo::end());
705        let result = SearchResult::new(page);
706        assert!(result.included.is_empty());
707        assert!(result.total.is_none());
708    }
709
710    #[test]
711    fn test_search_result_with_included() {
712        let page = Page::new(Vec::new(), PageInfo::end());
713        let result = SearchResult::new(page)
714            .with_included(vec![StoredResource::new(
715                "Patient",
716                "123",
717                crate::tenant::TenantId::new("t1"),
718                serde_json::json!({}),
719                FhirVersion::default(),
720            )])
721            .with_total(100);
722
723        assert_eq!(result.included.len(), 1);
724        assert_eq!(result.total, Some(100));
725    }
726
727    #[test]
728    fn test_search_result_to_bundle() {
729        let resource = StoredResource::new(
730            "Patient",
731            "123",
732            crate::tenant::TenantId::new("t1"),
733            serde_json::json!({"resourceType": "Patient", "id": "123"}),
734            FhirVersion::default(),
735        );
736
737        let page = Page::new(vec![resource], PageInfo::end());
738        let result = SearchResult::new(page).with_total(1);
739
740        let bundle = result.to_bundle("http://example.com/fhir", "http://example.com/fhir/Patient");
741
742        assert_eq!(bundle.total, Some(1));
743        assert_eq!(bundle.entry.len(), 1);
744        // No score recorded -> entry.search.score stays absent.
745        assert!(bundle.entry[0].search.as_ref().unwrap().score.is_none());
746    }
747
748    #[test]
749    fn test_search_result_to_bundle_attaches_score() {
750        let resource = StoredResource::new(
751            "Patient",
752            "123",
753            crate::tenant::TenantId::new("t1"),
754            serde_json::json!({"resourceType": "Patient", "id": "123"}),
755            FhirVersion::default(),
756        );
757        let page = Page::new(vec![resource], PageInfo::end());
758        let mut scores = HashMap::new();
759        scores.insert("Patient/123".to_string(), 0.875);
760        let result = SearchResult::new(page).with_scores(scores);
761
762        let bundle = result.to_bundle("http://example.com/fhir", "http://example.com/fhir/Patient");
763
764        assert_eq!(bundle.entry.len(), 1);
765        assert_eq!(
766            bundle.entry[0].search.as_ref().unwrap().score,
767            Some(0.875),
768            "the matched entry carries Bundle.entry.search.score"
769        );
770    }
771
772    /// Self-link has no query: cursor is appended with `?` (issue #69 bug 1).
773    #[test]
774    fn test_replace_cursor_param_no_query() {
775        let url = replace_cursor_param("http://example.com/fhir/Patient", "abc");
776        assert_eq!(url, "http://example.com/fhir/Patient?_cursor=abc");
777    }
778
779    /// Self-link already has params: cursor is joined with `&`, not `?`. This is
780    /// the core regression — see issue #69. A literal `?` mid-query made
781    /// `urljoin` percent-encode the cursor delimiter, breaking pagination.
782    #[test]
783    fn test_replace_cursor_param_with_existing_params() {
784        let url = replace_cursor_param(
785            "http://example.com/fhir/Patient?_count=3&_elements=id",
786            "abc",
787        );
788        assert_eq!(
789            url,
790            "http://example.com/fhir/Patient?_count=3&_elements=id&_cursor=abc"
791        );
792    }
793
794    /// When the self-link already carries the previous page's cursor (because
795    /// the request URL is reused as the self link), the old cursor is dropped
796    /// before the new one is appended — otherwise pages accumulate stale
797    /// cursors and the next link grows unbounded.
798    #[test]
799    fn test_replace_cursor_param_replaces_existing_cursor() {
800        let url = replace_cursor_param(
801            "http://example.com/fhir/Patient?_count=3&_cursor=old&_elements=id",
802            "new",
803        );
804        assert!(url.starts_with("http://example.com/fhir/Patient?"));
805        assert!(url.contains("_count=3"));
806        assert!(url.contains("_elements=id"));
807        assert!(url.contains("_cursor=new"));
808        assert!(!url.contains("_cursor=old"));
809        assert_eq!(url.matches("_cursor=").count(), 1);
810    }
811
812    /// `to_bundle` should produce a `next` link whose URL contains exactly one
813    /// `_cursor` and uses `&` between query params.
814    #[test]
815    fn test_to_bundle_next_link_format() {
816        let page = Page::new(
817            Vec::<StoredResource>::new(),
818            PageInfo {
819                next_cursor: Some("CURSOR_VALUE".to_string()),
820                previous_cursor: None,
821                total: None,
822                has_next: true,
823                has_previous: false,
824            },
825        );
826        let result = SearchResult::new(page);
827
828        let bundle = result.to_bundle(
829            "http://example.com/fhir",
830            "http://example.com/fhir/Patient?_count=3&_elements=id",
831        );
832
833        let next = bundle
834            .link
835            .iter()
836            .find(|l| l.relation == "next")
837            .expect("next link present");
838        assert_eq!(
839            next.url,
840            "http://example.com/fhir/Patient?_count=3&_elements=id&_cursor=CURSOR_VALUE"
841        );
842        assert_eq!(
843            next.url.matches('?').count(),
844            1,
845            "exactly one '?' delimiter"
846        );
847    }
848}