helios_sof/
compartment.rs1use helios_fhir::FhirVersion;
25use helios_fhirpath::{EvaluationContext, EvaluationResult, evaluate_expression};
26use serde_json::Value;
27use std::collections::HashSet;
28
29use crate::SofError;
30
31fn compartment_param_expressions(
35 fhir_version: FhirVersion,
36 compartment_type: &str,
37 resource_type: &str,
38) -> &'static [(&'static str, &'static str)] {
39 match fhir_version {
40 #[cfg(feature = "R4")]
41 FhirVersion::R4 => {
42 helios_fhir::compartment_expressions::r4::get_compartment_param_expressions(
43 compartment_type,
44 resource_type,
45 )
46 }
47 #[cfg(feature = "R4B")]
48 FhirVersion::R4B => {
49 helios_fhir::compartment_expressions::r4b::get_compartment_param_expressions(
50 compartment_type,
51 resource_type,
52 )
53 }
54 #[cfg(feature = "R5")]
55 FhirVersion::R5 => {
56 helios_fhir::compartment_expressions::r5::get_compartment_param_expressions(
57 compartment_type,
58 resource_type,
59 )
60 }
61 #[cfg(feature = "R6")]
62 FhirVersion::R6 => {
63 helios_fhir::compartment_expressions::r6::get_compartment_param_expressions(
64 compartment_type,
65 resource_type,
66 )
67 }
68 #[allow(unreachable_patterns)]
69 _ => &[],
70 }
71}
72
73pub fn resource_in_patient_compartment(
81 resource: &Value,
82 patient_refs: &HashSet<String>,
83 fhir_version: FhirVersion,
84) -> Result<bool, SofError> {
85 let Some(resource_type) = resource.get("resourceType").and_then(|v| v.as_str()) else {
86 return Ok(false);
87 };
88
89 if resource_type == "Patient" {
91 return Ok(resource
92 .get("id")
93 .and_then(|v| v.as_str())
94 .map(|id| patient_refs.contains(&format!("Patient/{}", id)))
95 .unwrap_or(false));
96 }
97
98 let expressions = compartment_param_expressions(fhir_version, "Patient", resource_type);
99 if expressions.is_empty() {
100 return Ok(false);
101 }
102
103 let fhir_resource = crate::parse_json_to_fhir_resource_pub(resource.clone(), fhir_version)?;
105 let context = EvaluationContext::new(vec![fhir_resource]);
106
107 for (_name, expression) in expressions {
108 let result = match evaluate_expression(expression, &context) {
109 Ok(r) => r,
110 Err(_) => continue,
115 };
116
117 if any_reference_matches(&result, patient_refs) {
118 return Ok(true);
119 }
120 }
121
122 Ok(false)
123}
124
125fn any_reference_matches(result: &EvaluationResult, targets: &HashSet<String>) -> bool {
128 match result {
129 EvaluationResult::Empty => false,
130 EvaluationResult::Collection { items, .. } => {
131 items.iter().any(|it| any_reference_matches(it, targets))
132 }
133 EvaluationResult::Object { map, .. } => {
134 if let Some(reference) = map.get("reference") {
135 if let Some(s) = extract_string(reference) {
136 if targets.contains(s) {
137 return true;
138 }
139 }
140 }
141 false
142 }
143 EvaluationResult::String(s, _, _) => targets.contains(s.as_str()),
144 _ => false,
145 }
146}
147
148fn extract_string(result: &EvaluationResult) -> Option<&str> {
151 if let EvaluationResult::String(s, _, _) = result {
152 Some(s.as_str())
153 } else {
154 None
155 }
156}
157
158pub fn resolve_group_members_to_patient_refs(
165 group_refs: &[String],
166 inline_resources: &[Value],
167) -> HashSet<String> {
168 let mut wanted: HashSet<String> = group_refs.iter().cloned().collect();
169 let mut patient_refs = HashSet::new();
170
171 for resource in inline_resources {
172 if resource.get("resourceType").and_then(|v| v.as_str()) != Some("Group") {
173 continue;
174 }
175 let Some(id) = resource.get("id").and_then(|v| v.as_str()) else {
176 continue;
177 };
178 let group_key_with_prefix = format!("Group/{}", id);
179 if !wanted.contains(&group_key_with_prefix) && !wanted.contains(id) {
180 continue;
181 }
182 wanted.remove(&group_key_with_prefix);
183 wanted.remove(id);
184
185 if let Some(members) = resource.get("member").and_then(|v| v.as_array()) {
186 for member in members {
187 if let Some(entity_ref) = member
188 .get("entity")
189 .and_then(|e| e.get("reference"))
190 .and_then(|r| r.as_str())
191 {
192 if entity_ref.starts_with("Patient/") {
193 patient_refs.insert(entity_ref.to_string());
194 }
195 }
196 }
197 }
198 }
199
200 patient_refs
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use serde_json::json;
207
208 #[cfg(feature = "R4")]
209 #[test]
210 fn patient_compartment_includes_allergyintolerance_via_patient_ref() {
211 let ai = json!({
214 "resourceType": "AllergyIntolerance",
215 "id": "ai-1",
216 "patient": {"reference": "Patient/abc"},
217 });
218
219 let mut targets = HashSet::new();
220 targets.insert("Patient/abc".to_string());
221
222 assert!(resource_in_patient_compartment(&ai, &targets, FhirVersion::R4).unwrap());
223 }
224
225 #[cfg(feature = "R4")]
231 #[test]
232 fn patient_compartment_includes_appointment_via_nested_participant_actor() {
233 let appt_alice = json!({
234 "resourceType": "Appointment",
235 "id": "appt-alice",
236 "status": "booked",
237 "participant": [
238 {"actor": {"reference": "Patient/alice"}, "status": "accepted"}
239 ]
240 });
241 let appt_bob = json!({
242 "resourceType": "Appointment",
243 "id": "appt-bob",
244 "status": "booked",
245 "participant": [
246 {"actor": {"reference": "Patient/bob"}, "status": "accepted"}
247 ]
248 });
249
250 let mut targets = HashSet::new();
251 targets.insert("Patient/alice".to_string());
252
253 assert!(
254 resource_in_patient_compartment(&appt_alice, &targets, FhirVersion::R4).unwrap(),
255 "Appointment for Patient/alice must be in alice's compartment via participant.actor"
256 );
257 assert!(
258 !resource_in_patient_compartment(&appt_bob, &targets, FhirVersion::R4).unwrap(),
259 "Appointment for Patient/bob must NOT be in alice's compartment"
260 );
261 }
262
263 #[cfg(feature = "R4")]
264 #[test]
265 fn patient_resource_matches_only_its_own_id() {
266 let patient = json!({"resourceType": "Patient", "id": "abc"});
267
268 let mut matching = HashSet::new();
269 matching.insert("Patient/abc".to_string());
270 let mut nonmatching = HashSet::new();
271 nonmatching.insert("Patient/xyz".to_string());
272
273 assert!(resource_in_patient_compartment(&patient, &matching, FhirVersion::R4).unwrap());
274 assert!(!resource_in_patient_compartment(&patient, &nonmatching, FhirVersion::R4).unwrap());
275 }
276
277 #[cfg(feature = "R4")]
278 #[test]
279 fn unrelated_resource_is_not_in_compartment() {
280 let lib = json!({"resourceType": "Library", "id": "lib-1"});
282
283 let mut targets = HashSet::new();
284 targets.insert("Patient/abc".to_string());
285
286 assert!(!resource_in_patient_compartment(&lib, &targets, FhirVersion::R4).unwrap());
287 }
288
289 #[test]
290 fn group_members_resolve_to_patient_refs() {
291 let group = json!({
292 "resourceType": "Group",
293 "id": "g1",
294 "member": [
295 {"entity": {"reference": "Patient/a"}},
296 {"entity": {"reference": "Patient/b"}},
297 {"entity": {"reference": "Practitioner/p1"}},
298 ]
299 });
300
301 let resolved = resolve_group_members_to_patient_refs(
302 &["Group/g1".to_string()],
303 std::slice::from_ref(&group),
304 );
305 assert!(resolved.contains("Patient/a"));
306 assert!(resolved.contains("Patient/b"));
307 assert!(!resolved.contains("Practitioner/p1"));
308 }
309
310 #[test]
311 fn group_accepts_bare_id_and_typed_ref() {
312 let group = json!({
313 "resourceType": "Group",
314 "id": "g2",
315 "member": [{"entity": {"reference": "Patient/a"}}]
316 });
317
318 let typed = resolve_group_members_to_patient_refs(
319 &["Group/g2".to_string()],
320 std::slice::from_ref(&group),
321 );
322 assert!(typed.contains("Patient/a"));
323
324 let bare = resolve_group_members_to_patient_refs(&["g2".to_string()], &[group]);
325 assert!(bare.contains("Patient/a"));
326 }
327}