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.3.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 ([namespace::]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>`. A key is written as the map compares it: as
410    /// declared in an exact map, and in its comparison form in an openEHR one,
411    /// so two declarations that differ only in the case of a key have one
412    /// text.
413    #[must_use]
414    pub fn canonical(&self) -> String {
415        let mut text = String::new();
416        if self.matching == Matching::OpenEhr {
417            text.push_str("matching\topenehr\n");
418        }
419        for national in &self.national {
420            let _written = writeln!(text, "national\t{}|{}", national.system(), national.code());
421        }
422        for (table, keys) in [
423            (Table::Templates, &self.templates),
424            (Table::Archetypes, &self.archetypes),
425        ] {
426            // NOTE: openEHR BASE 1.3.0 Base Types, Composite Identifiers and Case: under
427            // Matching::OpenEhr a key is written in its comparison form, so one map has one text.
428            for (key, (_, mapping)) in keys {
429                let value = match mapping {
430                    Mapping::NoCategory => NONE.to_owned(),
431                    Mapping::Categories(categories) => categories
432                        .iter()
433                        .map(Category::token)
434                        .collect::<Vec<_>>()
435                        .join(","),
436                };
437                let _written = writeln!(text, "{}\t{key}\t{value}", table.name());
438            }
439        }
440        text
441    }
442}
443
444/// The mapping `declared` states for `key` of `table`.
445fn mapping(
446    table: Table,
447    key: &str,
448    declared: &Declared,
449    national: &BTreeSet<NationalCategory>,
450) -> Result<Mapping, MapError> {
451    if !is_key(key) {
452        return Err(MapError::Key(table));
453    }
454    let codes = match declared {
455        Declared::Word(word) if word == NONE => return Ok(Mapping::NoCategory),
456        Declared::Word(_) => {
457            return Err(MapError::Word {
458                table,
459                key: key.to_owned(),
460            });
461        }
462        Declared::Codes(codes) if codes.is_empty() => {
463            return Err(MapError::Empty {
464                table,
465                key: key.to_owned(),
466            });
467        }
468        Declared::Codes(codes) => codes,
469    };
470    codes
471        .iter()
472        .map(|code| {
473            named(code, national).ok_or_else(|| MapError::Unknown {
474                table,
475                key: key.to_owned(),
476                code: code.clone(),
477            })
478        })
479        .collect::<Result<BTreeSet<_>, _>>()
480        .map(Mapping::Categories)
481}
482
483/// Whether `key` is not empty and holds no control character.
484fn is_key(key: &str) -> bool {
485    !key.is_empty() && !key.chars().any(char::is_control)
486}
487
488/// Whether `id` follows the `ARCHETYPE_ID` grammar: the namespaced ADL 2 form
489/// `namespace::rm_originator-rm_name-rm_entity.concept.vM.m.p-rc.N` or the
490/// legacy form `rm_originator-rm_name-rm_entity.concept.vM`.
491// NOTE: openEHR BASE 1.3.0, Base Types, Identification Package, Syntaxes; a namespaceless id
492// with a full version is admitted as FerroEHR's ArchetypeId admits it (AM 2.4.0 ARCHETYPE_HRID).
493fn is_archetype_id(id: &str) -> bool {
494    let alphanumeric = |part: &str| {
495        let mut characters = part.chars();
496        characters
497            .next()
498            .is_some_and(|first| first.is_ascii_alphabetic())
499            && characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_')
500    };
501    let extended = |part: &str| {
502        let mut characters = part.chars();
503        characters
504            .next()
505            .is_some_and(|first| first.is_ascii_alphabetic())
506            && characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_' || rest == '-')
507    };
508    let (namespace, rest) = match id.split_once("::") {
509        Some((namespace, rest)) => (Some(namespace), rest),
510        None => (None, id),
511    };
512    if namespace.is_some_and(|namespace| !namespace.split('.').all(extended)) {
513        return false;
514    }
515    let mut sections = rest.splitn(3, '.');
516    let (Some(entity), Some(concept), Some(version)) =
517        (sections.next(), sections.next(), sections.next())
518    else {
519        return false;
520    };
521    let mut entity_parts = entity.split('-');
522    let entity_ok = matches!(
523        (entity_parts.next(), entity_parts.next(), entity_parts.next(), entity_parts.next()),
524        (Some(originator), Some(name), Some(class), None)
525            if alphanumeric(originator) && alphanumeric(name) && alphanumeric(class)
526    );
527    let concept_ok = concept.split('-').all(alphanumeric);
528    let version_ok = version.strip_prefix('v').is_some_and(is_version_id);
529    entity_ok && concept_ok && version_ok
530}
531
532/// Whether `version` is a BASE `version_id`: `major [. minor [. patch
533/// [modifier]]]`, each number `0` or without a leading zero, and the modifier
534/// `-rc`, `-alpha` or `-beta` with an optional `.build`.
535fn is_version_id(version: &str) -> bool {
536    let number = |part: &str| {
537        !part.is_empty()
538            && part.bytes().all(|digit| digit.is_ascii_digit())
539            && (part == "0" || !part.starts_with('0'))
540    };
541    let (numeric, modifier) = match version.split_once('-') {
542        Some((numeric, modifier)) => (numeric, Some(modifier)),
543        None => (version, None),
544    };
545    let mut count = 0_usize;
546    for part in numeric.split('.') {
547        count = count.saturating_add(1);
548        if !number(part) {
549            return false;
550        }
551    }
552    let modifier_ok = modifier.is_none_or(|modifier| {
553        let (kind, build) = match modifier.split_once('.') {
554            Some((kind, build)) => (kind, Some(build)),
555            None => (modifier, None),
556        };
557        count == 3
558            && matches!(kind, "rc" | "alpha" | "beta")
559            && build.is_none_or(|build| {
560                !build.is_empty() && build.bytes().all(|digit| digit.is_ascii_digit())
561            })
562    });
563    (1..=3).contains(&count) && modifier_ok
564}
565
566/// The category `reference` names among the priority categories and the
567/// `national` ones.
568fn named(reference: &str, national: &BTreeSet<NationalCategory>) -> Option<Category> {
569    let reference = Reference::read(reference);
570    reference.priority().or_else(|| {
571        national
572            .iter()
573            .find(|declared| reference.names(declared))
574            .map(|declared| Category::National(declared.clone()))
575    })
576}