Skip to main content

helios_persistence/search/
loader.rs

1//! SearchParameter Loader.
2//!
3//! Loads SearchParameter definitions from multiple sources:
4//! - Embedded standard parameters (compiled into the binary)
5//! - FHIR spec bundle files (search-parameters-*.json)
6//! - Custom SearchParameter files in the data directory
7//! - Stored SearchParameter resources (from database)
8//! - Runtime configuration files
9
10use std::path::Path;
11
12use helios_fhir::FhirVersion;
13use regex::Regex;
14use serde_json::Value;
15
16use crate::types::SearchParamType;
17
18use super::errors::LoaderError;
19use super::registry::{
20    CompositeComponentDef, SearchParameterDefinition, SearchParameterSource, SearchParameterStatus,
21};
22
23/// Transforms FHIRPath expressions to replace `as` operator/function with `ofType`.
24///
25/// Per FHIRPath spec, `as(type)` requires singleton input and throws an error for
26/// collections with multiple items. However, many FHIR SearchParameter expressions
27/// use `as` on paths that can return multiple values (e.g., `Observation.component.value`).
28///
29/// This function rewrites such expressions to use `ofType()` which properly filters
30/// collections, making them compatible with strict FHIRPath evaluation.
31///
32/// Transformations:
33/// - `(X as Type)` → `(X.ofType(Type))` (operator form)
34/// - `X.as(Type)` → `X.ofType(Type)` (function form)
35///
36/// See: https://chat.fhir.org/#narrow/channel/179266-fhirpath/topic/FHIRPath.20Strictness.20in.20R4
37fn transform_as_to_oftype(expression: &str) -> String {
38    // First, handle the operator form: "X as Type" → "X.ofType(Type)"
39    // This regex matches: path/expression followed by " as " followed by type name
40    // We need to be careful with parentheses grouping
41    let operator_re = Regex::new(
42        r"(\([^()]*\)|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)"
43    ).unwrap();
44
45    let result = operator_re.replace_all(expression, |caps: &regex::Captures| {
46        let path = &caps[1];
47        let type_name = &caps[2];
48        format!("{}.ofType({})", path, type_name)
49    });
50
51    // Then handle the function form: ".as(Type)" → ".ofType(Type)"
52    let function_re = Regex::new(r"\.as\(([A-Za-z_][A-Za-z0-9_]*)\)").unwrap();
53    let result = function_re.replace_all(&result, ".ofType($1)");
54
55    result.into_owned()
56}
57
58/// Loader for SearchParameter definitions.
59pub struct SearchParameterLoader {
60    fhir_version: FhirVersion,
61}
62
63impl SearchParameterLoader {
64    /// Creates a new loader for the specified FHIR version.
65    pub fn new(fhir_version: FhirVersion) -> Self {
66        Self { fhir_version }
67    }
68
69    /// Returns the FHIR version.
70    pub fn version(&self) -> FhirVersion {
71        self.fhir_version
72    }
73
74    /// Returns the spec filename for the configured FHIR version.
75    #[allow(unreachable_patterns)]
76    pub fn spec_filename(&self) -> &'static str {
77        match self.fhir_version {
78            #[cfg(feature = "R4")]
79            FhirVersion::R4 => "search-parameters-r4.json",
80            #[cfg(feature = "R4B")]
81            FhirVersion::R4B => "search-parameters-r4b.json",
82            #[cfg(feature = "R5")]
83            FhirVersion::R5 => "search-parameters-r5.json",
84            #[cfg(feature = "R6")]
85            FhirVersion::R6 => "search-parameters-r6.json",
86            _ => "search-parameters-r4.json",
87        }
88    }
89
90    /// Loads embedded minimal fallback parameters for the FHIR version.
91    ///
92    /// This returns only the essential Resource-level search parameters that
93    /// should always be available as a fallback. For full FHIR spec compliance,
94    /// use `load_from_spec_file()` to load the complete parameter set.
95    pub fn load_embedded(&self) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
96        Ok(self.get_minimal_fallback_parameters())
97    }
98
99    /// Loads SearchParameter resources from a FHIR spec bundle file.
100    ///
101    /// Expects files in the format `search-parameters-{version}.json` in the
102    /// specified data directory, where version is r4, r4b, r5, or r6.
103    pub fn load_from_spec_file(
104        &self,
105        data_dir: &Path,
106    ) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
107        let path = data_dir.join(self.spec_filename());
108        let content =
109            std::fs::read_to_string(&path).map_err(|e| LoaderError::ConfigLoadFailed {
110                path: path.display().to_string(),
111                message: e.to_string(),
112            })?;
113        let json: Value =
114            serde_json::from_str(&content).map_err(|e| LoaderError::ConfigLoadFailed {
115                path: path.display().to_string(),
116                message: format!("Invalid JSON: {}", e),
117            })?;
118
119        let mut params = Vec::new();
120        let mut errors = Vec::new();
121
122        // Handle Bundle format (expected from FHIR spec files)
123        if let Some(entries) = json.get("entry").and_then(|e| e.as_array()) {
124            for entry in entries {
125                if let Some(resource) = entry.get("resource") {
126                    if resource.get("resourceType").and_then(|t| t.as_str())
127                        == Some("SearchParameter")
128                    {
129                        match self.parse_resource(resource) {
130                            Ok(mut param) => {
131                                param.source = SearchParameterSource::Embedded;
132                                // Treat draft params from spec files as active
133                                // (the FHIR spec uses "draft" for most standard params)
134                                if param.status == SearchParameterStatus::Draft {
135                                    param.status = SearchParameterStatus::Active;
136                                }
137                                params.push(param);
138                            }
139                            Err(e) => {
140                                // Log but continue - don't fail on individual params
141                                errors.push(e);
142                            }
143                        }
144                    }
145                }
146            }
147        }
148
149        if !errors.is_empty() {
150            tracing::warn!(
151                "Skipped {} invalid SearchParameters while loading spec file: {:?}",
152                errors.len(),
153                path
154            );
155        }
156
157        tracing::info!(
158            "Loaded {} SearchParameters from spec file: {:?}",
159            params.len(),
160            path
161        );
162
163        Ok(params)
164    }
165
166    /// Loads custom SearchParameter files from the data directory.
167    ///
168    /// Scans the data directory for JSON files that are not the standard
169    /// FHIR spec bundles (search-parameters-*.json). These files can contain:
170    /// - A single SearchParameter resource
171    /// - An array of SearchParameter resources
172    /// - A Bundle containing SearchParameter resources
173    ///
174    /// This allows organizations to add custom SearchParameters by placing
175    /// JSON files in the data directory.
176    pub fn load_custom_from_directory(
177        &self,
178        data_dir: &Path,
179    ) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
180        self.load_custom_from_directory_with_files(data_dir)
181            .map(|(params, _)| params)
182    }
183
184    /// Loads custom SearchParameter files from the data directory.
185    ///
186    /// Returns both the loaded parameters and the list of filenames that were loaded.
187    pub fn load_custom_from_directory_with_files(
188        &self,
189        data_dir: &Path,
190    ) -> Result<(Vec<SearchParameterDefinition>, Vec<String>), LoaderError> {
191        let mut params = Vec::new();
192        let mut loaded_files = Vec::new();
193        let mut errors = Vec::new();
194
195        // List of spec files to skip (loaded separately)
196        let spec_files = [
197            "search-parameters-r4.json",
198            "search-parameters-r4b.json",
199            "search-parameters-r5.json",
200            "search-parameters-r6.json",
201        ];
202
203        // Read directory entries
204        let entries = match std::fs::read_dir(data_dir) {
205            Ok(entries) => entries,
206            Err(e) => {
207                tracing::debug!(
208                    "Could not read data directory {}: {}",
209                    data_dir.display(),
210                    e
211                );
212                return Ok((params, loaded_files)); // Return empty - not an error
213            }
214        };
215
216        for entry in entries {
217            let entry = match entry {
218                Ok(e) => e,
219                Err(e) => {
220                    tracing::warn!("Failed to read directory entry: {}", e);
221                    continue;
222                }
223            };
224
225            let path = entry.path();
226
227            // Skip non-JSON files
228            if path.extension().is_none_or(|ext| ext != "json") {
229                continue;
230            }
231
232            // Skip spec files
233            let filename = match path.file_name().and_then(|n| n.to_str()) {
234                Some(name) => name.to_string(),
235                None => continue,
236            };
237            if spec_files.contains(&filename.as_str()) {
238                continue;
239            }
240
241            // Skip directories
242            if path.is_dir() {
243                continue;
244            }
245
246            // Try to load the file
247            match self.load_custom_file(&path) {
248                Ok(mut file_params) => {
249                    if !file_params.is_empty() {
250                        tracing::debug!(
251                            "Loaded {} custom SearchParameters from {}",
252                            file_params.len(),
253                            filename
254                        );
255                        params.append(&mut file_params);
256                        loaded_files.push(filename);
257                    }
258                }
259                Err(e) => {
260                    tracing::warn!(
261                        "Failed to load custom SearchParameter file {:?}: {}",
262                        path,
263                        e
264                    );
265                    errors.push(e);
266                }
267            }
268        }
269
270        if !errors.is_empty() {
271            tracing::warn!(
272                "Encountered {} errors while loading custom SearchParameters",
273                errors.len()
274            );
275        }
276
277        Ok((params, loaded_files))
278    }
279
280    /// Loads SearchParameters from a single custom file.
281    fn load_custom_file(&self, path: &Path) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
282        let content = std::fs::read_to_string(path).map_err(|e| LoaderError::ConfigLoadFailed {
283            path: path.display().to_string(),
284            message: e.to_string(),
285        })?;
286
287        let json: Value =
288            serde_json::from_str(&content).map_err(|e| LoaderError::ConfigLoadFailed {
289                path: path.display().to_string(),
290                message: format!("Invalid JSON: {}", e),
291            })?;
292
293        let mut params = self.load_from_json(&json)?;
294
295        // Mark all as config source
296        for param in &mut params {
297            param.source = SearchParameterSource::Config;
298        }
299
300        Ok(params)
301    }
302
303    /// Loads SearchParameter resources from a JSON bundle or array.
304    pub fn load_from_json(
305        &self,
306        json: &Value,
307    ) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
308        let mut params = Vec::new();
309
310        // Handle Bundle
311        if let Some(entries) = json.get("entry").and_then(|e| e.as_array()) {
312            for entry in entries {
313                if let Some(resource) = entry.get("resource") {
314                    if resource.get("resourceType").and_then(|t| t.as_str())
315                        == Some("SearchParameter")
316                    {
317                        params.push(self.parse_resource(resource)?);
318                    }
319                }
320            }
321        }
322        // Handle array of SearchParameter resources
323        else if let Some(array) = json.as_array() {
324            for item in array {
325                if item.get("resourceType").and_then(|t| t.as_str()) == Some("SearchParameter") {
326                    params.push(self.parse_resource(item)?);
327                }
328            }
329        }
330        // Handle single SearchParameter
331        else if json.get("resourceType").and_then(|t| t.as_str()) == Some("SearchParameter") {
332            params.push(self.parse_resource(json)?);
333        }
334
335        Ok(params)
336    }
337
338    /// Loads parameters from a configuration file.
339    pub fn load_config(
340        &self,
341        config_path: &Path,
342    ) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
343        let content =
344            std::fs::read_to_string(config_path).map_err(|e| LoaderError::ConfigLoadFailed {
345                path: config_path.display().to_string(),
346                message: e.to_string(),
347            })?;
348
349        let json: Value =
350            serde_json::from_str(&content).map_err(|e| LoaderError::ConfigLoadFailed {
351                path: config_path.display().to_string(),
352                message: format!("Invalid JSON: {}", e),
353            })?;
354
355        let mut params = self.load_from_json(&json)?;
356
357        // Mark all as config source
358        for param in &mut params {
359            param.source = SearchParameterSource::Config;
360        }
361
362        Ok(params)
363    }
364
365    /// Parses a SearchParameter FHIR resource into a definition.
366    pub fn parse_resource(
367        &self,
368        resource: &Value,
369    ) -> Result<SearchParameterDefinition, LoaderError> {
370        let url = resource
371            .get("url")
372            .and_then(|v| v.as_str())
373            .ok_or_else(|| LoaderError::MissingField {
374                field: "url".to_string(),
375                url: None,
376            })?
377            .to_string();
378
379        let code = resource
380            .get("code")
381            .and_then(|v| v.as_str())
382            .ok_or_else(|| LoaderError::MissingField {
383                field: "code".to_string(),
384                url: Some(url.clone()),
385            })?
386            .to_string();
387
388        let type_str = resource
389            .get("type")
390            .and_then(|v| v.as_str())
391            .ok_or_else(|| LoaderError::MissingField {
392                field: "type".to_string(),
393                url: Some(url.clone()),
394            })?;
395
396        let param_type =
397            type_str
398                .parse::<SearchParamType>()
399                .map_err(|_| LoaderError::InvalidResource {
400                    message: format!("Unknown search parameter type: {}", type_str),
401                    url: Some(url.clone()),
402                })?;
403
404        let raw_expression = resource
405            .get("expression")
406            .and_then(|v| v.as_str())
407            .unwrap_or("");
408
409        // Transform `as` to `ofType` for FHIRPath spec compliance
410        // Many SearchParameter expressions use `as` on collection paths which would
411        // fail with strict FHIRPath singleton requirements
412        let expression = if raw_expression.contains(" as ") || raw_expression.contains(".as(") {
413            transform_as_to_oftype(raw_expression)
414        } else {
415            raw_expression.to_string()
416        };
417
418        // For non-composite types, expression is required
419        if expression.is_empty() && param_type != SearchParamType::Composite {
420            // Some special parameters don't have expressions
421            if !code.starts_with('_') {
422                return Err(LoaderError::MissingField {
423                    field: "expression".to_string(),
424                    url: Some(url),
425                });
426            }
427        }
428
429        let base: Vec<String> = resource
430            .get("base")
431            .and_then(|v| v.as_array())
432            .map(|arr| {
433                arr.iter()
434                    .filter_map(|v| v.as_str().map(String::from))
435                    .collect()
436            })
437            .unwrap_or_default();
438
439        let target: Option<Vec<String>> =
440            resource
441                .get("target")
442                .and_then(|v| v.as_array())
443                .map(|arr| {
444                    arr.iter()
445                        .filter_map(|v| v.as_str().map(String::from))
446                        .collect()
447                });
448
449        let status = resource
450            .get("status")
451            .and_then(|v| v.as_str())
452            .and_then(SearchParameterStatus::from_fhir_status)
453            .unwrap_or(SearchParameterStatus::Active);
454
455        let component = self.parse_components(resource)?;
456
457        let modifier: Option<Vec<String>> = resource
458            .get("modifier")
459            .and_then(|v| v.as_array())
460            .map(|arr| {
461                arr.iter()
462                    .filter_map(|v| v.as_str().map(String::from))
463                    .collect()
464            });
465
466        let comparator: Option<Vec<String>> = resource
467            .get("comparator")
468            .and_then(|v| v.as_array())
469            .map(|arr| {
470                arr.iter()
471                    .filter_map(|v| v.as_str().map(String::from))
472                    .collect()
473            });
474
475        Ok(SearchParameterDefinition {
476            url,
477            code,
478            name: resource
479                .get("name")
480                .and_then(|v| v.as_str())
481                .map(String::from),
482            description: resource
483                .get("description")
484                .and_then(|v| v.as_str())
485                .map(String::from),
486            param_type,
487            expression,
488            base,
489            target,
490            component,
491            status,
492            source: SearchParameterSource::Stored,
493            modifier,
494            multiple_or: resource.get("multipleOr").and_then(|v| v.as_bool()),
495            multiple_and: resource.get("multipleAnd").and_then(|v| v.as_bool()),
496            comparator,
497            xpath: resource
498                .get("xpath")
499                .and_then(|v| v.as_str())
500                .map(String::from),
501        })
502    }
503
504    /// Parses composite components from a SearchParameter resource.
505    fn parse_components(
506        &self,
507        resource: &Value,
508    ) -> Result<Option<Vec<CompositeComponentDef>>, LoaderError> {
509        let components = match resource.get("component").and_then(|v| v.as_array()) {
510            Some(arr) => arr,
511            None => return Ok(None),
512        };
513
514        let mut result = Vec::new();
515        for comp in components {
516            let definition = comp
517                .get("definition")
518                .and_then(|v| v.as_str())
519                .ok_or_else(|| LoaderError::InvalidResource {
520                    message: "Composite component missing definition".to_string(),
521                    url: resource
522                        .get("url")
523                        .and_then(|v| v.as_str())
524                        .map(String::from),
525                })?
526                .to_string();
527
528            let expression = comp
529                .get("expression")
530                .and_then(|v| v.as_str())
531                .unwrap_or("")
532                .to_string();
533
534            result.push(CompositeComponentDef {
535                definition,
536                expression,
537            });
538        }
539
540        Ok(if result.is_empty() {
541            None
542        } else {
543            Some(result)
544        })
545    }
546
547    /// Returns minimal fallback search parameters for the FHIR version.
548    ///
549    /// This provides only the essential Resource-level parameters that should
550    /// always work, used when spec files are unavailable.
551    #[allow(clippy::vec_init_then_push)]
552    fn get_minimal_fallback_parameters(&self) -> Vec<SearchParameterDefinition> {
553        let mut params = Vec::new();
554
555        // Minimal parameters that work on all resource types
556        // Note: We use simplified expressions without "Resource." prefix since our FHIRPath
557        // evaluator doesn't support Resource type filtering. The FHIR spec uses "Resource.id",
558        // but we simplify to just "id" which works correctly when evaluated in the resource context.
559        params.push(
560            SearchParameterDefinition::new(
561                "http://hl7.org/fhir/SearchParameter/Resource-id",
562                "_id",
563                SearchParamType::Token,
564                "id",
565            )
566            .with_base(vec!["Resource"])
567            .with_source(SearchParameterSource::Embedded),
568        );
569
570        params.push(
571            SearchParameterDefinition::new(
572                "http://hl7.org/fhir/SearchParameter/Resource-lastUpdated",
573                "_lastUpdated",
574                SearchParamType::Date,
575                "meta.lastUpdated",
576            )
577            .with_base(vec!["Resource"])
578            .with_source(SearchParameterSource::Embedded),
579        );
580
581        params.push(
582            SearchParameterDefinition::new(
583                "http://hl7.org/fhir/SearchParameter/Resource-tag",
584                "_tag",
585                SearchParamType::Token,
586                "meta.tag",
587            )
588            .with_base(vec!["Resource"])
589            .with_source(SearchParameterSource::Embedded),
590        );
591
592        params.push(
593            SearchParameterDefinition::new(
594                "http://hl7.org/fhir/SearchParameter/Resource-profile",
595                "_profile",
596                SearchParamType::Uri,
597                "meta.profile",
598            )
599            .with_base(vec!["Resource"])
600            .with_source(SearchParameterSource::Embedded),
601        );
602
603        params.push(
604            SearchParameterDefinition::new(
605                "http://hl7.org/fhir/SearchParameter/Resource-security",
606                "_security",
607                SearchParamType::Token,
608                "meta.security",
609            )
610            .with_base(vec!["Resource"])
611            .with_source(SearchParameterSource::Embedded),
612        );
613
614        params
615    }
616}
617
618impl Default for SearchParameterLoader {
619    fn default() -> Self {
620        Self::new(FhirVersion::R4)
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn test_fhir_version() {
630        assert_eq!(FhirVersion::R4.as_str(), "R4");
631        assert_eq!(FhirVersion::default(), FhirVersion::R4);
632    }
633
634    #[test]
635    fn test_load_embedded_minimal_fallback() {
636        let loader = SearchParameterLoader::new(FhirVersion::R4);
637        let params = loader.load_embedded().unwrap();
638
639        // Minimal fallback only contains Resource-level params
640        assert!(!params.is_empty());
641        assert!(params.len() <= 5, "Minimal fallback should have ~5 params");
642
643        // Check for essential Resource-level parameters
644        let has_id = params.iter().any(|p| p.code == "_id");
645        assert!(has_id, "Should have _id parameter");
646
647        let has_last_updated = params.iter().any(|p| p.code == "_lastUpdated");
648        assert!(has_last_updated, "Should have _lastUpdated parameter");
649
650        // Should NOT have resource-specific parameters (those come from spec files)
651        let has_patient_specific = params
652            .iter()
653            .any(|p| p.code == "name" && p.base.contains(&"Patient".to_string()));
654        assert!(
655            !has_patient_specific,
656            "Minimal fallback should not have Patient-specific params"
657        );
658    }
659
660    #[test]
661    fn test_parse_resource() {
662        let loader = SearchParameterLoader::new(FhirVersion::R4);
663
664        let json = serde_json::json!({
665            "resourceType": "SearchParameter",
666            "url": "http://example.org/sp/test",
667            "code": "test",
668            "type": "string",
669            "expression": "Patient.test",
670            "base": ["Patient"],
671            "status": "active"
672        });
673
674        let param = loader.parse_resource(&json).unwrap();
675
676        assert_eq!(param.url, "http://example.org/sp/test");
677        assert_eq!(param.code, "test");
678        assert_eq!(param.param_type, SearchParamType::String);
679        assert_eq!(param.expression, "Patient.test");
680        assert!(param.base.contains(&"Patient".to_string()));
681        assert_eq!(param.status, SearchParameterStatus::Active);
682    }
683
684    #[test]
685    fn test_parse_resource_missing_field() {
686        let loader = SearchParameterLoader::new(FhirVersion::R4);
687
688        let json = serde_json::json!({
689            "resourceType": "SearchParameter",
690            "code": "test",
691            "type": "string"
692        });
693
694        let result = loader.parse_resource(&json);
695        assert!(matches!(result, Err(LoaderError::MissingField { field, .. }) if field == "url"));
696    }
697
698    #[test]
699    fn test_load_from_json_bundle() {
700        let loader = SearchParameterLoader::new(FhirVersion::R4);
701
702        let json = serde_json::json!({
703            "resourceType": "Bundle",
704            "entry": [
705                {
706                    "resource": {
707                        "resourceType": "SearchParameter",
708                        "url": "http://example.org/sp/test1",
709                        "code": "test1",
710                        "type": "string",
711                        "expression": "Patient.test1",
712                        "base": ["Patient"]
713                    }
714                },
715                {
716                    "resource": {
717                        "resourceType": "SearchParameter",
718                        "url": "http://example.org/sp/test2",
719                        "code": "test2",
720                        "type": "token",
721                        "expression": "Patient.test2",
722                        "base": ["Patient"]
723                    }
724                }
725            ]
726        });
727
728        let params = loader.load_from_json(&json).unwrap();
729        assert_eq!(params.len(), 2);
730    }
731
732    #[test]
733    fn test_parse_composite_components() {
734        let loader = SearchParameterLoader::new(FhirVersion::R4);
735
736        let json = serde_json::json!({
737            "resourceType": "SearchParameter",
738            "url": "http://example.org/sp/composite",
739            "code": "composite-test",
740            "type": "composite",
741            "expression": "",
742            "base": ["Observation"],
743            "component": [
744                {
745                    "definition": "http://hl7.org/fhir/SearchParameter/Observation-code",
746                    "expression": "code"
747                },
748                {
749                    "definition": "http://hl7.org/fhir/SearchParameter/Observation-value-quantity",
750                    "expression": "value"
751                }
752            ]
753        });
754
755        let param = loader.parse_resource(&json).unwrap();
756        assert!(param.is_composite());
757        assert_eq!(param.component.as_ref().unwrap().len(), 2);
758    }
759
760    #[test]
761    fn test_load_custom_from_directory() {
762        use std::fs;
763
764        // Create a temp directory for testing
765        let temp_dir = std::env::temp_dir().join("hfs_loader_test");
766        let _ = fs::remove_dir_all(&temp_dir); // Clean up any previous test
767        fs::create_dir_all(&temp_dir).unwrap();
768
769        // Create a custom SearchParameter file
770        let custom_param = serde_json::json!({
771            "resourceType": "SearchParameter",
772            "url": "http://example.org/sp/custom-mrn",
773            "code": "mrn",
774            "type": "token",
775            "expression": "Patient.identifier.where(type.coding.code='MR')",
776            "base": ["Patient"],
777            "status": "active"
778        });
779        let custom_file = temp_dir.join("custom-params.json");
780        fs::write(
781            &custom_file,
782            serde_json::to_string_pretty(&custom_param).unwrap(),
783        )
784        .unwrap();
785
786        // Create a spec file that should be skipped
787        let spec_file = temp_dir.join("search-parameters-r4.json");
788        fs::write(&spec_file, "{}").unwrap(); // Empty file, would fail if read
789
790        // Create a non-JSON file that should be skipped
791        let txt_file = temp_dir.join("readme.txt");
792        fs::write(&txt_file, "This should be skipped").unwrap();
793
794        // Load custom parameters
795        let loader = SearchParameterLoader::new(FhirVersion::R4);
796        let params = loader.load_custom_from_directory(&temp_dir).unwrap();
797
798        assert_eq!(params.len(), 1);
799        assert_eq!(params[0].code, "mrn");
800        assert_eq!(params[0].url, "http://example.org/sp/custom-mrn");
801        assert_eq!(params[0].source, SearchParameterSource::Config);
802
803        // Clean up
804        let _ = fs::remove_dir_all(&temp_dir);
805    }
806
807    #[test]
808    fn test_load_custom_from_directory_bundle() {
809        use std::fs;
810
811        // Create a temp directory for testing
812        let temp_dir = std::env::temp_dir().join("hfs_loader_test_bundle");
813        let _ = fs::remove_dir_all(&temp_dir);
814        fs::create_dir_all(&temp_dir).unwrap();
815
816        // Create a Bundle with multiple SearchParameters
817        let bundle = serde_json::json!({
818            "resourceType": "Bundle",
819            "type": "collection",
820            "entry": [
821                {
822                    "resource": {
823                        "resourceType": "SearchParameter",
824                        "url": "http://example.org/sp/custom1",
825                        "code": "custom1",
826                        "type": "string",
827                        "expression": "Patient.name.family",
828                        "base": ["Patient"]
829                    }
830                },
831                {
832                    "resource": {
833                        "resourceType": "SearchParameter",
834                        "url": "http://example.org/sp/custom2",
835                        "code": "custom2",
836                        "type": "token",
837                        "expression": "Patient.identifier",
838                        "base": ["Patient"]
839                    }
840                }
841            ]
842        });
843        let bundle_file = temp_dir.join("custom-bundle.json");
844        fs::write(&bundle_file, serde_json::to_string_pretty(&bundle).unwrap()).unwrap();
845
846        // Load custom parameters
847        let loader = SearchParameterLoader::new(FhirVersion::R4);
848        let params = loader.load_custom_from_directory(&temp_dir).unwrap();
849
850        assert_eq!(params.len(), 2);
851        assert!(params.iter().any(|p| p.code == "custom1"));
852        assert!(params.iter().any(|p| p.code == "custom2"));
853
854        // Clean up
855        let _ = fs::remove_dir_all(&temp_dir);
856    }
857
858    #[test]
859    fn test_load_custom_from_nonexistent_directory() {
860        use std::path::PathBuf;
861
862        let loader = SearchParameterLoader::new(FhirVersion::R4);
863        let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
864
865        // Should return empty vec, not error
866        let params = loader.load_custom_from_directory(&nonexistent).unwrap();
867        assert!(params.is_empty());
868    }
869
870    #[test]
871    fn test_transform_as_to_oftype() {
872        // Test operator form: "X as Type" → "X.ofType(Type)"
873        assert_eq!(
874            transform_as_to_oftype("Observation.value as CodeableConcept"),
875            "Observation.value.ofType(CodeableConcept)"
876        );
877
878        // Test with parentheses (common in SearchParameter expressions)
879        assert_eq!(
880            transform_as_to_oftype("(Observation.value as CodeableConcept)"),
881            "(Observation.value.ofType(CodeableConcept))"
882        );
883
884        // Test union expression (the actual problematic case)
885        assert_eq!(
886            transform_as_to_oftype(
887                "(Observation.value as CodeableConcept) | (Observation.component.value as CodeableConcept)"
888            ),
889            "(Observation.value.ofType(CodeableConcept)) | (Observation.component.value.ofType(CodeableConcept))"
890        );
891
892        // Test function form: ".as(Type)" → ".ofType(Type)"
893        assert_eq!(
894            transform_as_to_oftype("Patient.name.as(HumanName)"),
895            "Patient.name.ofType(HumanName)"
896        );
897
898        // Test expression without 'as' should be unchanged
899        assert_eq!(
900            transform_as_to_oftype("Patient.name.family"),
901            "Patient.name.family"
902        );
903
904        // Test expression with ofType already should be unchanged
905        assert_eq!(
906            transform_as_to_oftype("Observation.value.ofType(Quantity)"),
907            "Observation.value.ofType(Quantity)"
908        );
909    }
910
911    #[test]
912    fn test_parse_resource_transforms_as_expression() {
913        let loader = SearchParameterLoader::new(FhirVersion::R4);
914
915        // SearchParameter with 'as' operator should be transformed
916        let json = serde_json::json!({
917            "resourceType": "SearchParameter",
918            "url": "http://example.org/sp/test",
919            "code": "test",
920            "type": "token",
921            "expression": "(Observation.value as CodeableConcept) | (Observation.component.value as CodeableConcept)",
922            "base": ["Observation"],
923            "status": "active"
924        });
925
926        let param = loader.parse_resource(&json).unwrap();
927
928        // Expression should be transformed to use ofType
929        assert_eq!(
930            param.expression,
931            "(Observation.value.ofType(CodeableConcept)) | (Observation.component.value.ofType(CodeableConcept))"
932        );
933    }
934}