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}