Skip to main content

ehds_logging/
category.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The categories of personal electronic health data an access record names
5//! (Regulation (EU) 2025/327 Annex II 3.2(c)).
6//!
7//! Art 14(1) fixes six priority categories, points (a) to (f), and lets a
8//! Member State add categories in its national law (Art 14(1) third
9//! subparagraph). A [`Category`] is one of the six, or a national category a
10//! deployment declares. Each is written as a coded value, the way a FHIR R4
11//! `Coding` is: a [`system`](Category::system), the
12//! [`version`](Category::version) of that system when known, and a
13//! [`code`](Category::code) in it.
14//!
15//! The six priority categories carry the codes of HL7 Europe's
16//! `EEHRxFDocumentPriorityCategoryCS` ([`PRIORITY_SYSTEM`], version
17//! [`PRIORITY_VERSION`], `hl7.fhir.eu.health-data-api` 1.0.0-ballot), whose
18//! displays are the Art 14(1) terms and whose definitions are the Annex I
19//! text. That code system is case-sensitive, so a code is compared exactly. A
20//! national category carries the system the Member State, or the deployment
21//! in its place, names, and the national code as it stands.
22//!
23//! Where a record or a configuration holds a category as one string, it is
24//! the FHIR R4 search token form, `<system>|<code>`, which
25//! [`Category::token`] writes and [`Reference`] reads.
26
27use std::fmt;
28use std::str::FromStr;
29
30/// The canonical URL of the code system of the six priority categories.
31///
32/// It is HL7 Europe's `EEHRxFDocumentPriorityCategoryCS`
33/// (`hl7.fhir.eu.health-data-api` 1.0.0-ballot,
34/// `CodeSystem/eehrxf-document-priority-category-cs`).
35pub const PRIORITY_SYSTEM: &str =
36    "http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cs";
37
38/// The version of [`PRIORITY_SYSTEM`] the priority codes are read from.
39pub const PRIORITY_VERSION: &str = "1.0.0-ballot";
40
41/// A category of personal electronic health data (Art 14(1)).
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
43#[non_exhaustive]
44pub enum Category {
45    /// Art 14(1)(a), patient summaries: `Patient-Summaries`.
46    PatientSummary,
47    /// Art 14(1)(b), electronic prescriptions: `Electronic-Prescriptions`.
48    ElectronicPrescription,
49    /// Art 14(1)(c), electronic dispensations: `Electronic-Dispensations`.
50    ElectronicDispensation,
51    /// Art 14(1)(d), medical imaging studies and related imaging reports:
52    /// `Medical-Imaging`.
53    MedicalImaging,
54    /// Art 14(1)(e), medical test results, including laboratory and other
55    /// diagnostic results and related reports: `Laboratory-Reports`.
56    MedicalTestResult,
57    /// Art 14(1)(f), discharge reports: `Discharge-Reports`.
58    DischargeReport,
59    /// A category national law adds (Art 14(1) third subparagraph), by the
60    /// system and code the deployment declares for it.
61    National(NationalCategory),
62}
63
64impl Category {
65    /// The six priority categories of Art 14(1), points (a) to (f), in order.
66    pub const PRIORITY: [Self; 6] = [
67        Self::PatientSummary,
68        Self::ElectronicPrescription,
69        Self::ElectronicDispensation,
70        Self::MedicalImaging,
71        Self::MedicalTestResult,
72        Self::DischargeReport,
73    ];
74
75    /// The code system the category's code is defined in: [`PRIORITY_SYSTEM`]
76    /// for a priority category, the declared system for a national one.
77    #[must_use]
78    pub fn system(&self) -> &str {
79        match self {
80            Self::National(national) => national.system(),
81            _ => PRIORITY_SYSTEM,
82        }
83    }
84
85    /// The version of the code system, when it is known:
86    /// [`PRIORITY_VERSION`] for a priority category, `None` for a national
87    /// one.
88    #[must_use]
89    pub fn version(&self) -> Option<&str> {
90        match self {
91            Self::National(_) => None,
92            _ => Some(PRIORITY_VERSION),
93        }
94    }
95
96    /// The code of the category in its [`system`](Category::system).
97    #[must_use]
98    pub fn code(&self) -> &str {
99        match self {
100            Self::PatientSummary => "Patient-Summaries",
101            Self::ElectronicPrescription => "Electronic-Prescriptions",
102            Self::ElectronicDispensation => "Electronic-Dispensations",
103            Self::MedicalImaging => "Medical-Imaging",
104            Self::MedicalTestResult => "Laboratory-Reports",
105            Self::DischargeReport => "Discharge-Reports",
106            Self::National(national) => national.code(),
107        }
108    }
109
110    /// The category as one string, `<system>|<code>`, the FHIR R4 search
111    /// token form.
112    #[must_use]
113    pub fn token(&self) -> String {
114        format!("{}|{}", self.system(), self.code())
115    }
116
117    /// The priority category `code` names in [`PRIORITY_SYSTEM`], compared
118    /// exactly, or `None` for any other code.
119    #[must_use]
120    pub fn priority(code: &str) -> Option<Self> {
121        Self::PRIORITY
122            .into_iter()
123            .find(|category| category.code() == code)
124    }
125}
126
127impl fmt::Display for Category {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(f, "{}|{}", self.system(), self.code())
130    }
131}
132
133/// A category national law adds: the system that defines it and its code.
134///
135/// It is written `<system>|<code>`. The system is an absolute URI that holds
136/// no `|` and is not [`PRIORITY_SYSTEM`]; the code is a FHIR R4 `code`: at
137/// least one character, no leading or trailing whitespace, and no whitespace
138/// other than single spaces (FHIR R4 Datatypes, `code`), and no character
139/// below U+0020 (FHIR R4 Datatypes, `string`).
140#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
141pub struct NationalCategory {
142    system: String,
143    code: String,
144}
145
146impl NationalCategory {
147    /// The national category `code` in `system`.
148    ///
149    /// # Errors
150    ///
151    /// [`CodeError::System`] for a system that is no absolute URI or holds a
152    /// `|`, [`CodeError::Priority`] for [`PRIORITY_SYSTEM`], and
153    /// [`CodeError::Code`] for a code that is no FHIR `code`.
154    pub fn new(system: &str, code: &str) -> Result<Self, CodeError> {
155        if system == PRIORITY_SYSTEM {
156            return Err(CodeError::Priority);
157        }
158        if !is_absolute_uri(system) {
159            return Err(CodeError::System);
160        }
161        if !is_fhir_code(code) {
162            return Err(CodeError::Code);
163        }
164        Ok(Self {
165            system: system.to_owned(),
166            code: code.to_owned(),
167        })
168    }
169
170    /// The system.
171    #[must_use]
172    pub fn system(&self) -> &str {
173        &self.system
174    }
175
176    /// The code.
177    #[must_use]
178    pub fn code(&self) -> &str {
179        &self.code
180    }
181}
182
183impl FromStr for NationalCategory {
184    type Err = CodeError;
185
186    /// Reads `<system>|<code>`.
187    fn from_str(token: &str) -> Result<Self, Self::Err> {
188        let (system, code) = token.split_once('|').ok_or(CodeError::NoSystem)?;
189        Self::new(system, code)
190    }
191}
192
193/// Why a national category is refused.
194#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
195#[non_exhaustive]
196pub enum CodeError {
197    /// The category names no system: it is not written `<system>|<code>`.
198    #[error("a national category is written <system>|<code>")]
199    NoSystem,
200    /// The system is no absolute URI, or it holds a `|`.
201    #[error("the system of a national category is an absolute URI without `|`")]
202    System,
203    /// The system is the code system of the priority categories.
204    #[error("a national category names a system of its own, never {PRIORITY_SYSTEM}")]
205    Priority,
206    /// The code is empty, has leading or trailing whitespace, holds
207    /// whitespace other than single spaces, or holds a character below U+0020.
208    #[error(
209        "a national category code is a FHIR code: no leading, trailing or repeated whitespace, and no character below U+0020"
210    )]
211    Code,
212}
213
214/// How a configuration names a category: a bare priority code, or
215/// `<system>|<code>`.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Reference<'a> {
218    /// A bare code, which names a priority category.
219    Bare(&'a str),
220    /// A code in a system.
221    Coded {
222        /// The system.
223        system: &'a str,
224        /// The code.
225        code: &'a str,
226    },
227}
228
229impl<'a> Reference<'a> {
230    /// Reads `text`: `<system>|<code>` when it holds a `|`, a bare code
231    /// otherwise.
232    #[must_use]
233    pub fn read(text: &'a str) -> Self {
234        match text.split_once('|') {
235            Some((system, code)) => Self::Coded { system, code },
236            None => Self::Bare(text),
237        }
238    }
239
240    /// The priority category this names, or `None` when it names another.
241    #[must_use]
242    pub fn priority(self) -> Option<Category> {
243        match self {
244            Self::Bare(code)
245            | Self::Coded {
246                system: PRIORITY_SYSTEM,
247                code,
248            } => Category::priority(code),
249            Self::Coded { .. } => None,
250        }
251    }
252
253    /// Whether this names `national`.
254    #[must_use]
255    pub fn names(self, national: &NationalCategory) -> bool {
256        matches!(self, Self::Coded { system, code } if system == national.system() && code == national.code())
257    }
258}
259
260/// Whether `system` is an absolute URI with no `|` and no whitespace: a
261/// scheme of RFC 3986 ยง3.1 followed by `:` and at least one character.
262pub(crate) fn is_absolute_uri(system: &str) -> bool {
263    let Some((scheme, rest)) = system.split_once(':') else {
264        return false;
265    };
266    let mut letters = scheme.chars();
267    let scheme_formed = letters
268        .next()
269        .is_some_and(|first| first.is_ascii_alphabetic())
270        && letters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
271    scheme_formed
272        && !rest.is_empty()
273        && !system
274            .chars()
275            .any(|c| c == '|' || c.is_whitespace() || c.is_control())
276}
277
278/// Whether `code` is a FHIR R4 `code` (`[^\s]+(\s[^\s]+)*`, the whitespace a
279/// single space) with no character below U+0020.
280pub(crate) fn is_fhir_code(code: &str) -> bool {
281    !code.is_empty()
282        && !code.starts_with(' ')
283        && !code.ends_with(' ')
284        && !code.contains("  ")
285        && !code
286            .chars()
287            .any(|c| c < ' ' || (c.is_whitespace() && c != ' '))
288}