Skip to main content

fml_engine/
terminology.rs

1//! ConceptMaps for the `translate` transform.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use serde_json::Value;
7
8/// Finds ConceptMaps by canonical URL, for `translate(value, 'url', output)`.
9pub trait ConceptMapResolver {
10    fn resolve(&self, url: &str) -> Option<Arc<Value>>;
11}
12
13/// No ConceptMaps besides those contained in the map.
14#[derive(Debug, Default, Clone, Copy)]
15pub struct NoConceptMaps;
16
17impl ConceptMapResolver for NoConceptMaps {
18    fn resolve(&self, _url: &str) -> Option<Arc<Value>> {
19        None
20    }
21}
22
23/// A fixed set of ConceptMaps (FHIR R4 or R5 JSON), found by `url`, `url|version`, and by the
24/// canonical URLs they replace (`relatedArtifact` of type `predecessor`), so maps that still use
25/// an old canonical find the renamed ConceptMap.
26#[derive(Debug, Default, Clone)]
27pub struct ConceptMapSet {
28    by_url: HashMap<String, Arc<Value>>,
29}
30
31impl ConceptMapSet {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Adds a ConceptMap; other resources are ignored. Returns whether it was one.
37    pub fn add(&mut self, concept_map: Value) -> bool {
38        if concept_map["resourceType"] != "ConceptMap" {
39            return false;
40        }
41        let concept_map = Arc::new(concept_map);
42        let mut keys = Vec::new();
43        if let Some(url) = concept_map["url"].as_str() {
44            keys.push(url.to_owned());
45            if let Some(version) = concept_map["version"].as_str() {
46                keys.push(format!("{url}|{version}"));
47            }
48        }
49        for artifact in concept_map["relatedArtifact"]
50            .as_array()
51            .into_iter()
52            .flatten()
53        {
54            if artifact["type"] == "predecessor"
55                && let Some(resource) = artifact["resource"].as_str()
56            {
57                keys.push(resource.to_owned());
58            }
59        }
60        for key in keys {
61            self.by_url
62                .entry(key)
63                .or_insert_with(|| concept_map.clone());
64        }
65        true
66    }
67
68    pub fn len(&self) -> usize {
69        self.by_url.len()
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.by_url.is_empty()
74    }
75}
76
77impl ConceptMapResolver for ConceptMapSet {
78    fn resolve(&self, url: &str) -> Option<Arc<Value>> {
79        self.by_url
80            .get(url)
81            .or_else(|| self.by_url.get(url.split('|').next().unwrap_or(url)))
82            .cloned()
83    }
84}
85
86/// The result of translating a code: the target system and the mapped target entry.
87#[derive(Debug, Clone, PartialEq)]
88pub struct Translation {
89    pub system: Option<String>,
90    pub code: Option<String>,
91    pub display: Option<String>,
92}
93
94/// Translates `code` (from `system`, when known) with a ConceptMap in R5 or R4 form, as the
95/// reference engine does: the first element for the code, and among its targets the one whose
96/// relationship is `related-to`, `equivalent` or `source-is-narrower-than-target` (or none);
97/// several such targets are ambiguous. Without an element for the code, the group's unmapped
98/// rule applies (`use-source-code`, `fixed`, R4 `provided`). `Ok(None)`: the ConceptMap does not
99/// know the code, and the reference engine leaves the target unset. An element whose targets are
100/// all unrelated is an error.
101pub fn translate(
102    concept_map: &Value,
103    system: Option<&str>,
104    code: &str,
105) -> Result<Option<Translation>, String> {
106    let base = |uri: &str| uri.split('|').next().unwrap_or(uri).to_owned();
107    for group in concept_map["group"].as_array().into_iter().flatten() {
108        if let (Some(system), Some(source)) = (system, group["source"].as_str())
109            && base(source) != base(system)
110        {
111            continue;
112        }
113        let target_system = group["target"].as_str().map(base);
114        if let Some(element) = group["element"]
115            .as_array()
116            .into_iter()
117            .flatten()
118            .find(|element| element["code"].as_str() == Some(code))
119        {
120            let accepted: Vec<&Value> = element["target"]
121                .as_array()
122                .into_iter()
123                .flatten()
124                .filter(|target| usable(target))
125                .collect();
126            return match accepted.as_slice() {
127                [] => Err(format!("`{code}` has no usable translation")),
128                [target] => Ok(Some(Translation {
129                    system: target_system,
130                    code: target["code"].as_str().map(str::to_owned),
131                    display: target["display"].as_str().map(str::to_owned),
132                })),
133                _ => Err(format!("`{code}` has several translations")),
134            };
135        }
136        let unmapped = &group["unmapped"];
137        match unmapped["mode"].as_str() {
138            Some("use-source-code" | "provided") => {
139                return Ok(Some(Translation {
140                    system: target_system,
141                    code: Some(code.to_owned()),
142                    display: None,
143                }));
144            }
145            Some("fixed") => {
146                return Ok(Some(Translation {
147                    system: target_system,
148                    code: unmapped["code"].as_str().map(str::to_owned),
149                    display: unmapped["display"].as_str().map(str::to_owned),
150                }));
151            }
152            _ => {}
153        }
154    }
155    Ok(None)
156}
157
158/// A target the reference engine translates to: an R5 relationship of `related-to`,
159/// `equivalent` or `source-is-narrower-than-target`, the R4 equivalences that convert to those,
160/// or no relationship at all.
161fn usable(target: &Value) -> bool {
162    match (
163        target["relationship"].as_str(),
164        target["equivalence"].as_str(),
165    ) {
166        (Some(relationship), _) => matches!(
167            relationship,
168            "related-to" | "equivalent" | "source-is-narrower-than-target"
169        ),
170        (None, Some(equivalence)) => matches!(
171            equivalence,
172            "relatedto" | "inexact" | "equivalent" | "equal" | "wider" | "specializes"
173        ),
174        (None, None) => true,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use serde_json::json;
181
182    use super::*;
183
184    fn concept_map() -> Value {
185        json!({
186            "resourceType": "ConceptMap",
187            "url": "http://example.org/ConceptMap/new",
188            "version": "1.0",
189            "relatedArtifact": [{ "type": "predecessor", "resource": "http://example.org/ConceptMap/old" }],
190            "group": [{
191                "source": "http://example.org/a|4.0.1",
192                "target": "http://example.org/b",
193                "element": [
194                    { "code": "x", "target": [{ "code": "X", "display": "Ex", "relationship": "equivalent" }] },
195                    { "code": "y", "target": [{ "code": "Y", "relationship": "not-related-to" }] },
196                    { "code": "z", "target": [{ "code": "Z", "equivalence": "wider" }] }
197                ],
198                "unmapped": { "mode": "fixed", "code": "UNK" }
199            }]
200        })
201    }
202
203    #[test]
204    fn finds_maps_by_url_version_and_predecessor() {
205        let mut set = ConceptMapSet::new();
206        assert!(set.add(concept_map()));
207        assert!(!set.add(json!({ "resourceType": "ValueSet", "url": "http://example.org/vs" })));
208        for url in [
209            "http://example.org/ConceptMap/new",
210            "http://example.org/ConceptMap/new|1.0",
211            "http://example.org/ConceptMap/new|2.0",
212            "http://example.org/ConceptMap/old",
213        ] {
214            assert!(set.resolve(url).is_some(), "{url}");
215        }
216    }
217
218    #[test]
219    fn translates_codes() {
220        let map = concept_map();
221        let x = translate(&map, Some("http://example.org/a"), "x")
222            .unwrap()
223            .unwrap();
224        assert_eq!(x.code.as_deref(), Some("X"));
225        assert_eq!(x.system.as_deref(), Some("http://example.org/b"));
226        assert_eq!(x.display.as_deref(), Some("Ex"));
227        assert_eq!(
228            translate(&map, None, "z").unwrap().unwrap().code.as_deref(),
229            Some("Z")
230        );
231        // The code is mapped, but only to an unrelated concept.
232        assert!(translate(&map, None, "y").is_err());
233        // An unmapped code takes the group's unmapped rule.
234        assert_eq!(
235            translate(&map, None, "q").unwrap().unwrap().code.as_deref(),
236            Some("UNK")
237        );
238        assert_eq!(translate(&map, Some("http://other.org"), "x"), Ok(None));
239    }
240}