1use 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
23fn transform_as_to_oftype(expression: &str) -> String {
38 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: ®ex::Captures| {
46 let path = &caps[1];
47 let type_name = &caps[2];
48 format!("{}.ofType({})", path, type_name)
49 });
50
51 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
58pub struct SearchParameterLoader {
60 fhir_version: FhirVersion,
61}
62
63impl SearchParameterLoader {
64 pub fn new(fhir_version: FhirVersion) -> Self {
66 Self { fhir_version }
67 }
68
69 pub fn version(&self) -> FhirVersion {
71 self.fhir_version
72 }
73
74 #[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 pub fn load_embedded(&self) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
96 Ok(self.get_minimal_fallback_parameters())
97 }
98
99 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 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 if param.status == SearchParameterStatus::Draft {
135 param.status = SearchParameterStatus::Active;
136 }
137 params.push(param);
138 }
139 Err(e) => {
140 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 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 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 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 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)); }
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 if path.extension().is_none_or(|ext| ext != "json") {
229 continue;
230 }
231
232 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 if path.is_dir() {
243 continue;
244 }
245
246 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 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 for param in &mut params {
297 param.source = SearchParameterSource::Config;
298 }
299
300 Ok(params)
301 }
302
303 pub fn load_from_json(
305 &self,
306 json: &Value,
307 ) -> Result<Vec<SearchParameterDefinition>, LoaderError> {
308 let mut params = Vec::new();
309
310 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 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 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 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 for param in &mut params {
359 param.source = SearchParameterSource::Config;
360 }
361
362 Ok(params)
363 }
364
365 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 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 if expression.is_empty() && param_type != SearchParamType::Composite {
420 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 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 #[allow(clippy::vec_init_then_push)]
552 fn get_minimal_fallback_parameters(&self) -> Vec<SearchParameterDefinition> {
553 let mut params = Vec::new();
554
555 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 assert!(!params.is_empty());
641 assert!(params.len() <= 5, "Minimal fallback should have ~5 params");
642
643 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 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 let temp_dir = std::env::temp_dir().join("hfs_loader_test");
766 let _ = fs::remove_dir_all(&temp_dir); fs::create_dir_all(&temp_dir).unwrap();
768
769 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 let spec_file = temp_dir.join("search-parameters-r4.json");
788 fs::write(&spec_file, "{}").unwrap(); let txt_file = temp_dir.join("readme.txt");
792 fs::write(&txt_file, "This should be skipped").unwrap();
793
794 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 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 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 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 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 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 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 assert_eq!(
874 transform_as_to_oftype("Observation.value as CodeableConcept"),
875 "Observation.value.ofType(CodeableConcept)"
876 );
877
878 assert_eq!(
880 transform_as_to_oftype("(Observation.value as CodeableConcept)"),
881 "(Observation.value.ofType(CodeableConcept))"
882 );
883
884 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 assert_eq!(
894 transform_as_to_oftype("Patient.name.as(HumanName)"),
895 "Patient.name.ofType(HumanName)"
896 );
897
898 assert_eq!(
900 transform_as_to_oftype("Patient.name.family"),
901 "Patient.name.family"
902 );
903
904 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 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 assert_eq!(
930 param.expression,
931 "(Observation.value.ofType(CodeableConcept)) | (Observation.component.value.ofType(CodeableConcept))"
932 );
933 }
934}