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//! A key is compared exactly by default. Declared with [`Matching::OpenEhr`],
16//! the map compares template and archetype ids as openEHR BASE asks of every
17//! composite identifier: case-insensitively, while the evidence keeps each id
18//! as the access named it (BASE Release 1.1.0, Base Types, Identification
19//! Package, "Composite Identifiers and Case"). Archetype keys are held to the
20//! `ARCHETYPE_ID` grammar of the same package either way.
21//!
22//! The map's [`CategoryMap::canonical`] text is the same for the same
23//! declarations in any order, so a digest of it names the map a record was
24//! classified under.
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt::{self, Write as _};
28
29use serde::{Deserialize, Serialize};
30
31use crate::category::{Category, CodeError, NationalCategory, Reference};
32
33/// The word a declaration uses for data of no category.
34pub const NONE: &str = "none";
35
36/// One declaration as a configuration writes it: the word `none`, or a list
37/// of categories.
38#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
39#[serde(untagged)]
40pub enum Declared {
41    /// A word, of which only [`NONE`] is admitted.
42    Word(String),
43    /// A list of categories, at least one: a priority category by its bare
44    /// code or as `<system>|<code>`, a national one as `<system>|<code>`.
45    Codes(Vec<String>),
46}
47
48/// What a key maps to.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Mapping {
51    /// The data under the key belong to these categories.
52    Categories(BTreeSet<Category>),
53    /// The data under the key belong to no category.
54    NoCategory,
55}
56
57/// How a map compares an id with its keys.
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum Matching {
61    /// Exactly: an id that differs from a key by case is unmapped.
62    #[default]
63    Exact,
64    /// As openEHR BASE compares composite identifiers: two ids identical
65    /// apart from case identify the same thing. BASE limits their textual
66    /// parts to the basic latin letters, so the comparison folds ASCII case.
67    OpenEhr,
68}
69
70impl Matching {
71    /// `id` as this matching compares it.
72    fn key(self, id: &str) -> String {
73        match self {
74            Self::Exact => id.to_owned(),
75            Self::OpenEhr => id.to_ascii_lowercase(),
76        }
77    }
78}
79
80/// The two tables a map keys on.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Table {
83    /// Template ids.
84    Templates,
85    /// Archetype ids.
86    Archetypes,
87}
88
89impl Table {
90    /// The table's name, as a configuration and an error name it.
91    #[must_use]
92    pub fn name(self) -> &'static str {
93        match self {
94            Self::Templates => "templates",
95            Self::Archetypes => "archetypes",
96        }
97    }
98}
99
100/// Why a map was refused.
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102#[non_exhaustive]
103pub enum MapError {
104    /// A key is empty or holds a control character.
105    #[error("a key of the {} table is empty or holds a control character", .0.name())]
106    Key(Table),
107    /// A key maps to a word other than `none`.
108    #[error("{} key {key:?} maps to a word other than \"none\"", .table.name())]
109    Word {
110        /// The table.
111        table: Table,
112        /// The key.
113        key: String,
114    },
115    /// A key maps to an empty list.
116    #[error("{} key {key:?} maps to no category; write \"none\" for data of no category", .table.name())]
117    Empty {
118        /// The table.
119        table: Table,
120        /// The key.
121        key: String,
122    },
123    /// A key maps to a code that is neither a priority category nor a
124    /// declared national one.
125    #[error("{} key {key:?} maps to {code:?}, which is neither a priority category nor a declared national one", .table.name())]
126    Unknown {
127        /// The table.
128        table: Table,
129        /// The key.
130        key: String,
131        /// The code.
132        code: String,
133    },
134    /// A declared national category is refused.
135    #[error("the national category {code:?} is refused")]
136    National {
137        /// The category as declared.
138        code: String,
139        /// Why.
140        #[source]
141        source: CodeError,
142    },
143    /// An archetype key is no `ARCHETYPE_ID`.
144    #[error(
145        "archetypes key {key:?} is no openEHR archetype id (rm_originator-rm_name-rm_entity.concept.vN)"
146    )]
147    ArchetypeId {
148        /// The key.
149        key: String,
150    },
151    /// Two keys of a table name the same id under the map's matching.
152    #[error("{} keys {first:?} and {second:?} name the same id", .table.name())]
153    DuplicateKey {
154        /// The table.
155        table: Table,
156        /// The first key.
157        first: String,
158        /// The key that names the same id.
159        second: String,
160    },
161}
162
163/// Every defect of a refused map declaration, in table and key order.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct MapErrors(Vec<MapError>);
166
167impl MapErrors {
168    /// Each defect.
169    #[must_use]
170    pub fn errors(&self) -> &[MapError] {
171        &self.0
172    }
173
174    /// The first defect.
175    #[must_use]
176    pub fn first(&self) -> Option<&MapError> {
177        self.0.first()
178    }
179
180    /// The first defect, taken.
181    #[expect(
182        clippy::expect_used,
183        reason = "declare_with returns MapErrors only after pushing a defect, so it is never empty"
184    )]
185    fn into_first(self) -> MapError {
186        self.0
187            .into_iter()
188            .next()
189            .expect("a refused declaration should hold a defect")
190    }
191}
192
193impl fmt::Display for MapErrors {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        write!(f, "the category map has {} defects", self.0.len())?;
196        for error in &self.0 {
197            write!(f, "; {error}")?;
198        }
199        Ok(())
200    }
201}
202
203impl std::error::Error for MapErrors {}
204
205impl IntoIterator for MapErrors {
206    type Item = MapError;
207    type IntoIter = std::vec::IntoIter<MapError>;
208
209    fn into_iter(self) -> Self::IntoIter {
210        self.0.into_iter()
211    }
212}
213
214/// The deployment's category map.
215#[derive(Debug, Clone, Default, PartialEq, Eq)]
216pub struct CategoryMap {
217    national: BTreeSet<NationalCategory>,
218    matching: Matching,
219    templates: BTreeMap<String, (String, Mapping)>,
220    archetypes: BTreeMap<String, (String, Mapping)>,
221    digest: Option<String>,
222    evidence_limit: Option<usize>,
223}
224
225impl CategoryMap {
226    /// The map `templates` and `archetypes` declare, their categories read
227    /// against the six priority categories and the `national` ones, each
228    /// declared `<system>|<code>`, its keys compared exactly.
229    ///
230    /// # Errors
231    ///
232    /// The first [`MapError`] [`CategoryMap::declare_with`] finds.
233    pub fn declare(
234        national: &[String],
235        templates: &BTreeMap<String, Declared>,
236        archetypes: &BTreeMap<String, Declared>,
237    ) -> Result<Self, MapError> {
238        Self::declare_with(Matching::Exact, national, templates, archetypes)
239            .map_err(MapErrors::into_first)
240    }
241
242    /// The map `templates` and `archetypes` declare, compared by `matching`,
243    /// their categories read against the six priority categories and the
244    /// `national` ones, each declared `<system>|<code>`.
245    ///
246    /// # Errors
247    ///
248    /// [`MapErrors`] with every defect: a refused national code, an empty or
249    /// malformed key, an archetype key that is no `ARCHETYPE_ID`, two keys that
250    /// name the same id, a word other than `none`, an empty list, and a code
251    /// that names no category.
252    pub fn declare_with(
253        matching: Matching,
254        national: &[String],
255        templates: &BTreeMap<String, Declared>,
256        archetypes: &BTreeMap<String, Declared>,
257    ) -> Result<Self, MapErrors> {
258        let mut errors = Vec::new();
259        let mut declared = BTreeSet::new();
260        for code in national {
261            match code.parse::<NationalCategory>() {
262                Ok(category) => {
263                    declared.insert(category);
264                }
265                Err(source) => errors.push(MapError::National {
266                    code: code.clone(),
267                    source,
268                }),
269            }
270        }
271        let mut read = |table: Table, keys: &BTreeMap<String, Declared>| {
272            let mut read: BTreeMap<String, (String, Mapping)> = BTreeMap::new();
273            for (key, value) in keys {
274                if table == Table::Archetypes && !is_archetype_id(key) && is_key(key) {
275                    errors.push(MapError::ArchetypeId { key: key.clone() });
276                    continue;
277                }
278                match mapping(table, key, value, &declared) {
279                    Ok(mapping) => {
280                        let folded = matching.key(key);
281                        if let Some((first, _)) = read.get(&folded) {
282                            errors.push(MapError::DuplicateKey {
283                                table,
284                                first: first.clone(),
285                                second: key.clone(),
286                            });
287                        } else {
288                            read.insert(folded, (key.clone(), mapping));
289                        }
290                    }
291                    Err(error) => errors.push(error),
292                }
293            }
294            read
295        };
296        let templates = read(Table::Templates, templates);
297        let archetypes = read(Table::Archetypes, archetypes);
298        if !errors.is_empty() {
299            return Err(MapErrors(errors));
300        }
301        Ok(Self {
302            national: declared,
303            matching,
304            templates,
305            archetypes,
306            digest: None,
307            evidence_limit: None,
308        })
309    }
310
311    /// This map, keeping at most `limit` ids in each evidence set of a
312    /// classification, with each set's true count beside it.
313    #[must_use]
314    pub fn with_evidence_limit(mut self, limit: usize) -> Self {
315        self.evidence_limit = Some(limit);
316        self
317    }
318
319    /// The most ids a classification keeps in each evidence set, when the
320    /// map bounds them.
321    #[must_use]
322    pub fn evidence_limit(&self) -> Option<usize> {
323        self.evidence_limit
324    }
325
326    /// How the map compares an id with its keys.
327    #[must_use]
328    pub fn matching(&self) -> Matching {
329        self.matching
330    }
331
332    /// Every template key, as declared, with what it maps to, in key order.
333    pub fn templates(&self) -> impl Iterator<Item = (&str, &Mapping)> {
334        self.templates
335            .values()
336            .map(|(key, mapping)| (key.as_str(), mapping))
337    }
338
339    /// Every archetype key, as declared, with what it maps to, in key order.
340    pub fn archetypes(&self) -> impl Iterator<Item = (&str, &Mapping)> {
341        self.archetypes
342            .values()
343            .map(|(key, mapping)| (key.as_str(), mapping))
344    }
345
346    /// Every national category the map declares, in order.
347    pub fn national(&self) -> impl Iterator<Item = &NationalCategory> {
348        self.national.iter()
349    }
350
351    /// This map, named by `digest`, such as a hash of
352    /// [`CategoryMap::canonical`].
353    #[must_use]
354    pub fn with_digest(mut self, digest: String) -> Self {
355        self.digest = Some(digest);
356        self
357    }
358
359    /// The digest the map is named by, when one was given.
360    #[must_use]
361    pub fn digest(&self) -> Option<&str> {
362        self.digest.as_deref()
363    }
364
365    /// What `template_id` maps to, when the map holds it under its
366    /// matching.
367    #[must_use]
368    pub fn template(&self, template_id: &str) -> Option<&Mapping> {
369        self.templates
370            .get(&self.matching.key(template_id))
371            .map(|(_, mapping)| mapping)
372    }
373
374    /// What `archetype_id` maps to, when the map holds it under its
375    /// matching.
376    #[must_use]
377    pub fn archetype(&self, archetype_id: &str) -> Option<&Mapping> {
378        self.archetypes
379            .get(&self.matching.key(archetype_id))
380            .map(|(_, mapping)| mapping)
381    }
382
383    /// The category `reference` names: a priority category by its bare code
384    /// or as `<system>|<code>`, or a national one the map declares, as
385    /// `<system>|<code>`. Codes are compared exactly.
386    #[must_use]
387    pub fn category(&self, reference: &str) -> Option<Category> {
388        named(reference, &self.national)
389    }
390
391    /// Every category the map can name: the six priority categories of Art
392    /// 14(1) in their order, then each national category it declares.
393    pub fn categories(&self) -> impl Iterator<Item = Category> + '_ {
394        Category::PRIORITY
395            .into_iter()
396            .chain(self.national.iter().cloned().map(Category::National))
397    }
398
399    /// Whether the map holds no key.
400    #[must_use]
401    pub fn is_empty(&self) -> bool {
402        self.templates.is_empty() && self.archetypes.is_empty()
403    }
404
405    /// The map as text that is the same for the same declarations: a line
406    /// `matching\topenehr` for a map compared as openEHR BASE asks, one line
407    /// per national category, then per template and per archetype key, each
408    /// field separated by a tab, the categories by `,`, each category as
409    /// `<system>|<code>`.
410    #[must_use]
411    pub fn canonical(&self) -> String {
412        let mut text = String::new();
413        if self.matching == Matching::OpenEhr {
414            text.push_str("matching\topenehr\n");
415        }
416        for national in &self.national {
417            let _written = writeln!(text, "national\t{}|{}", national.system(), national.code());
418        }
419        for (table, keys) in [
420            (Table::Templates, &self.templates),
421            (Table::Archetypes, &self.archetypes),
422        ] {
423            for (key, mapping) in keys.values() {
424                let value = match mapping {
425                    Mapping::NoCategory => NONE.to_owned(),
426                    Mapping::Categories(categories) => categories
427                        .iter()
428                        .map(Category::token)
429                        .collect::<Vec<_>>()
430                        .join(","),
431                };
432                let _written = writeln!(text, "{}\t{key}\t{value}", table.name());
433            }
434        }
435        text
436    }
437}
438
439/// The mapping `declared` states for `key` of `table`.
440fn mapping(
441    table: Table,
442    key: &str,
443    declared: &Declared,
444    national: &BTreeSet<NationalCategory>,
445) -> Result<Mapping, MapError> {
446    if !is_key(key) {
447        return Err(MapError::Key(table));
448    }
449    let codes = match declared {
450        Declared::Word(word) if word == NONE => return Ok(Mapping::NoCategory),
451        Declared::Word(_) => {
452            return Err(MapError::Word {
453                table,
454                key: key.to_owned(),
455            });
456        }
457        Declared::Codes(codes) if codes.is_empty() => {
458            return Err(MapError::Empty {
459                table,
460                key: key.to_owned(),
461            });
462        }
463        Declared::Codes(codes) => codes,
464    };
465    codes
466        .iter()
467        .map(|code| {
468            named(code, national).ok_or_else(|| MapError::Unknown {
469                table,
470                key: key.to_owned(),
471                code: code.clone(),
472            })
473        })
474        .collect::<Result<BTreeSet<_>, _>>()
475        .map(Mapping::Categories)
476}
477
478/// Whether `key` is not empty and holds no control character.
479fn is_key(key: &str) -> bool {
480    !key.is_empty() && !key.chars().any(char::is_control)
481}
482
483/// Whether `id` follows the `ARCHETYPE_ID` grammar.
484// NOTE: openEHR BASE 1.1.0, Base Types, Identification Package, Syntaxes: the ARCHETYPE_ID
485// grammar, each part `letter {letter | digit | '_'}` and the version `'v' non-zero-digit [number]`.
486fn is_archetype_id(id: &str) -> bool {
487    let alphanumeric = |part: &str| {
488        let mut characters = part.chars();
489        characters
490            .next()
491            .is_some_and(|first| first.is_ascii_alphabetic())
492            && characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_')
493    };
494    let mut sections = id.split('.');
495    let (Some(entity), Some(concept), Some(version), None) = (
496        sections.next(),
497        sections.next(),
498        sections.next(),
499        sections.next(),
500    ) else {
501        return false;
502    };
503    let mut entity_parts = entity.split('-');
504    let entity_ok = matches!(
505        (entity_parts.next(), entity_parts.next(), entity_parts.next(), entity_parts.next()),
506        (Some(originator), Some(name), Some(class), None)
507            if alphanumeric(originator) && alphanumeric(name) && alphanumeric(class)
508    );
509    let concept_ok = concept.split('-').all(alphanumeric);
510    let version_ok = version.strip_prefix('v').is_some_and(|number| {
511        let mut digits = number.chars();
512        digits
513            .next()
514            .is_some_and(|first| matches!(first, '1'..='9'))
515            && digits.all(|digit| digit.is_ascii_digit())
516    });
517    entity_ok && concept_ok && version_ok
518}
519
520/// The category `reference` names among the priority categories and the
521/// `national` ones.
522fn named(reference: &str, national: &BTreeSet<NationalCategory>) -> Option<Category> {
523    let reference = Reference::read(reference);
524    reference.priority().or_else(|| {
525        national
526            .iter()
527            .find(|declared| reference.names(declared))
528            .map(|declared| Category::National(declared.clone()))
529    })
530}