1use std::collections::{BTreeMap, BTreeSet};
27use std::fmt::{self, Write as _};
28
29use serde::{Deserialize, Serialize};
30
31use crate::category::{Category, CodeError, NationalCategory, Reference};
32
33pub const NONE: &str = "none";
35
36#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
39#[serde(untagged)]
40pub enum Declared {
41 Word(String),
43 Codes(Vec<String>),
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Mapping {
51 Categories(BTreeSet<Category>),
53 NoCategory,
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum Matching {
61 #[default]
63 Exact,
64 OpenEhr,
68}
69
70impl Matching {
71 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Table {
83 Templates,
85 Archetypes,
87}
88
89impl Table {
90 #[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102#[non_exhaustive]
103pub enum MapError {
104 #[error("a key of the {} table is empty or holds a control character", .0.name())]
106 Key(Table),
107 #[error("{} key {key:?} maps to a word other than \"none\"", .table.name())]
109 Word {
110 table: Table,
112 key: String,
114 },
115 #[error("{} key {key:?} maps to no category; write \"none\" for data of no category", .table.name())]
117 Empty {
118 table: Table,
120 key: String,
122 },
123 #[error("{} key {key:?} maps to {code:?}, which is neither a priority category nor a declared national one", .table.name())]
126 Unknown {
127 table: Table,
129 key: String,
131 code: String,
133 },
134 #[error("the national category {code:?} is refused")]
136 National {
137 code: String,
139 #[source]
141 source: CodeError,
142 },
143 #[error(
145 "archetypes key {key:?} is no openEHR archetype id ([namespace::]rm_originator-rm_name-rm_entity.concept.vN)"
146 )]
147 ArchetypeId {
148 key: String,
150 },
151 #[error("{} keys {first:?} and {second:?} name the same id", .table.name())]
153 DuplicateKey {
154 table: Table,
156 first: String,
158 second: String,
160 },
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct MapErrors(Vec<MapError>);
166
167impl MapErrors {
168 #[must_use]
170 pub fn errors(&self) -> &[MapError] {
171 &self.0
172 }
173
174 #[must_use]
176 pub fn first(&self) -> Option<&MapError> {
177 self.0.first()
178 }
179
180 #[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#[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 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 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 #[must_use]
314 pub fn with_evidence_limit(mut self, limit: usize) -> Self {
315 self.evidence_limit = Some(limit);
316 self
317 }
318
319 #[must_use]
322 pub fn evidence_limit(&self) -> Option<usize> {
323 self.evidence_limit
324 }
325
326 #[must_use]
328 pub fn matching(&self) -> Matching {
329 self.matching
330 }
331
332 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 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 pub fn national(&self) -> impl Iterator<Item = &NationalCategory> {
348 self.national.iter()
349 }
350
351 #[must_use]
354 pub fn with_digest(mut self, digest: String) -> Self {
355 self.digest = Some(digest);
356 self
357 }
358
359 #[must_use]
361 pub fn digest(&self) -> Option<&str> {
362 self.digest.as_deref()
363 }
364
365 #[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 #[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 #[must_use]
387 pub fn category(&self, reference: &str) -> Option<Category> {
388 named(reference, &self.national)
389 }
390
391 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 #[must_use]
401 pub fn is_empty(&self) -> bool {
402 self.templates.is_empty() && self.archetypes.is_empty()
403 }
404
405 #[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 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
444fn 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
483fn is_key(key: &str) -> bool {
485 !key.is_empty() && !key.chars().any(char::is_control)
486}
487
488fn 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
532fn 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
566fn 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}