Skip to main content

ehds_logging/
map.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The deployment's category map: which categories the data under a template
5//! or an archetype belong to (Regulation (EU) 2025/327 Annex II 3.2(c)).
6//!
7//! No adopted act and no openEHR specification ties a template or an
8//! archetype to an Art 14(1) category, so the deployment declares it: each
9//! template id and each archetype id maps to a set of categories, or to
10//! `none`, the deployment's statement that the data under it are of no
11//! category. The crate ships no map. A template key wins over an archetype
12//! key for the same object, because a template is the clinical context the
13//! archetype is used in (no specification governs the map: our own design).
14//!
15//! The map's [`CategoryMap::canonical`] text is the same for the same
16//! declarations in any order, so a digest of it names the map a record was
17//! classified under.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::fmt::Write as _;
21
22use serde::Deserialize;
23
24use crate::category::{Category, CodeError, NationalCategory, Reference};
25
26/// The word a declaration uses for data of no category.
27pub const NONE: &str = "none";
28
29/// One declaration as a configuration writes it: the word `none`, or a list
30/// of categories.
31#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
32#[serde(untagged)]
33pub enum Declared {
34    /// A word, of which only [`NONE`] is admitted.
35    Word(String),
36    /// A list of categories, at least one: a priority category by its bare
37    /// code or as `<system>|<code>`, a national one as `<system>|<code>`.
38    Codes(Vec<String>),
39}
40
41/// What a key maps to.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Mapping {
44    /// The data under the key belong to these categories.
45    Categories(BTreeSet<Category>),
46    /// The data under the key belong to no category.
47    NoCategory,
48}
49
50/// The two tables a map keys on.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Table {
53    /// Template ids.
54    Templates,
55    /// Archetype ids.
56    Archetypes,
57}
58
59impl Table {
60    /// The table's name, as a configuration and an error name it.
61    #[must_use]
62    pub fn name(self) -> &'static str {
63        match self {
64            Self::Templates => "templates",
65            Self::Archetypes => "archetypes",
66        }
67    }
68}
69
70/// Why a map was refused.
71#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
72#[non_exhaustive]
73pub enum MapError {
74    /// A key is empty or holds a control character.
75    #[error("a key of the {} table is empty or holds a control character", .0.name())]
76    Key(Table),
77    /// A key maps to a word other than `none`.
78    #[error("{} key {key:?} maps to a word other than \"none\"", .table.name())]
79    Word {
80        /// The table.
81        table: Table,
82        /// The key.
83        key: String,
84    },
85    /// A key maps to an empty list.
86    #[error("{} key {key:?} maps to no category; write \"none\" for data of no category", .table.name())]
87    Empty {
88        /// The table.
89        table: Table,
90        /// The key.
91        key: String,
92    },
93    /// A key maps to a code that is neither a priority category nor a
94    /// declared national one.
95    #[error("{} key {key:?} maps to {code:?}, which is neither a priority category nor a declared national one", .table.name())]
96    Unknown {
97        /// The table.
98        table: Table,
99        /// The key.
100        key: String,
101        /// The code.
102        code: String,
103    },
104    /// A declared national category is refused.
105    #[error("the national category {code:?} is refused")]
106    National {
107        /// The category as declared.
108        code: String,
109        /// Why.
110        #[source]
111        source: CodeError,
112    },
113}
114
115/// The deployment's category map.
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct CategoryMap {
118    national: BTreeSet<NationalCategory>,
119    templates: BTreeMap<String, Mapping>,
120    archetypes: BTreeMap<String, Mapping>,
121    digest: Option<String>,
122}
123
124impl CategoryMap {
125    /// The map `templates` and `archetypes` declare, their categories read
126    /// against the six priority categories and the `national` ones, each
127    /// declared `<system>|<code>`.
128    ///
129    /// # Errors
130    ///
131    /// A [`MapError`] for a refused national code, an empty or malformed
132    /// key, a word other than `none`, an empty list, and a code that names
133    /// no category.
134    pub fn declare(
135        national: &[String],
136        templates: &BTreeMap<String, Declared>,
137        archetypes: &BTreeMap<String, Declared>,
138    ) -> Result<Self, MapError> {
139        let national = national
140            .iter()
141            .map(|code| {
142                code.parse::<NationalCategory>()
143                    .map_err(|source| MapError::National {
144                        code: code.clone(),
145                        source,
146                    })
147            })
148            .collect::<Result<BTreeSet<_>, _>>()?;
149        let read = |table: Table, declared: &BTreeMap<String, Declared>| {
150            declared
151                .iter()
152                .map(|(key, declared)| Ok((key.clone(), mapping(table, key, declared, &national)?)))
153                .collect::<Result<BTreeMap<_, _>, MapError>>()
154        };
155        Ok(Self {
156            templates: read(Table::Templates, templates)?,
157            archetypes: read(Table::Archetypes, archetypes)?,
158            national,
159            digest: None,
160        })
161    }
162
163    /// This map, named by `digest`, such as a hash of
164    /// [`CategoryMap::canonical`].
165    #[must_use]
166    pub fn with_digest(mut self, digest: String) -> Self {
167        self.digest = Some(digest);
168        self
169    }
170
171    /// The digest the map is named by, when one was given.
172    #[must_use]
173    pub fn digest(&self) -> Option<&str> {
174        self.digest.as_deref()
175    }
176
177    /// What `template_id` maps to, when the map holds it.
178    #[must_use]
179    pub fn template(&self, template_id: &str) -> Option<&Mapping> {
180        self.templates.get(template_id)
181    }
182
183    /// What `archetype_id` maps to, when the map holds it.
184    #[must_use]
185    pub fn archetype(&self, archetype_id: &str) -> Option<&Mapping> {
186        self.archetypes.get(archetype_id)
187    }
188
189    /// The category `reference` names: a priority category by its bare code
190    /// or as `<system>|<code>`, or a national one the map declares, as
191    /// `<system>|<code>`. Codes are compared exactly.
192    #[must_use]
193    pub fn category(&self, reference: &str) -> Option<Category> {
194        named(reference, &self.national)
195    }
196
197    /// Every category the map can name: the six priority categories of Art
198    /// 14(1) in their order, then each national category it declares.
199    pub fn categories(&self) -> impl Iterator<Item = Category> + '_ {
200        Category::PRIORITY
201            .into_iter()
202            .chain(self.national.iter().cloned().map(Category::National))
203    }
204
205    /// Whether the map holds no key.
206    #[must_use]
207    pub fn is_empty(&self) -> bool {
208        self.templates.is_empty() && self.archetypes.is_empty()
209    }
210
211    /// The map as text that is the same for the same declarations: one line
212    /// per national category, then per template and per archetype key, each
213    /// field separated by a tab, the categories by `,`, each category as
214    /// `<system>|<code>`.
215    #[must_use]
216    pub fn canonical(&self) -> String {
217        let mut text = String::new();
218        for national in &self.national {
219            let _written = writeln!(text, "national\t{}|{}", national.system(), national.code());
220        }
221        for (table, keys) in [
222            (Table::Templates, &self.templates),
223            (Table::Archetypes, &self.archetypes),
224        ] {
225            for (key, mapping) in keys {
226                let value = match mapping {
227                    Mapping::NoCategory => NONE.to_owned(),
228                    Mapping::Categories(categories) => categories
229                        .iter()
230                        .map(Category::token)
231                        .collect::<Vec<_>>()
232                        .join(","),
233                };
234                let _written = writeln!(text, "{}\t{key}\t{value}", table.name());
235            }
236        }
237        text
238    }
239}
240
241/// The mapping `declared` states for `key` of `table`.
242fn mapping(
243    table: Table,
244    key: &str,
245    declared: &Declared,
246    national: &BTreeSet<NationalCategory>,
247) -> Result<Mapping, MapError> {
248    if key.is_empty() || key.chars().any(char::is_control) {
249        return Err(MapError::Key(table));
250    }
251    let codes = match declared {
252        Declared::Word(word) if word == NONE => return Ok(Mapping::NoCategory),
253        Declared::Word(_) => {
254            return Err(MapError::Word {
255                table,
256                key: key.to_owned(),
257            });
258        }
259        Declared::Codes(codes) if codes.is_empty() => {
260            return Err(MapError::Empty {
261                table,
262                key: key.to_owned(),
263            });
264        }
265        Declared::Codes(codes) => codes,
266    };
267    codes
268        .iter()
269        .map(|code| {
270            named(code, national).ok_or_else(|| MapError::Unknown {
271                table,
272                key: key.to_owned(),
273                code: code.clone(),
274            })
275        })
276        .collect::<Result<BTreeSet<_>, _>>()
277        .map(Mapping::Categories)
278}
279
280/// The category `reference` names among the priority categories and the
281/// `national` ones.
282fn named(reference: &str, national: &BTreeSet<NationalCategory>) -> Option<Category> {
283    let reference = Reference::read(reference);
284    reference.priority().or_else(|| {
285        national
286            .iter()
287            .find(|declared| reference.names(declared))
288            .map(|declared| Category::National(declared.clone()))
289    })
290}