use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{self, Write as _};
use serde::{Deserialize, Serialize};
use crate::category::{Category, CodeError, NationalCategory, Reference};
pub const NONE: &str = "none";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Declared {
Word(String),
Codes(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mapping {
Categories(BTreeSet<Category>),
NoCategory,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Matching {
#[default]
Exact,
OpenEhr,
}
impl Matching {
fn key(self, id: &str) -> String {
match self {
Self::Exact => id.to_owned(),
Self::OpenEhr => id.to_ascii_lowercase(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Table {
Templates,
Archetypes,
}
impl Table {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Templates => "templates",
Self::Archetypes => "archetypes",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum MapError {
#[error("a key of the {} table is empty or holds a control character", .0.name())]
Key(Table),
#[error("{} key {key:?} maps to a word other than \"none\"", .table.name())]
Word {
table: Table,
key: String,
},
#[error("{} key {key:?} maps to no category; write \"none\" for data of no category", .table.name())]
Empty {
table: Table,
key: String,
},
#[error("{} key {key:?} maps to {code:?}, which is neither a priority category nor a declared national one", .table.name())]
Unknown {
table: Table,
key: String,
code: String,
},
#[error("the national category {code:?} is refused")]
National {
code: String,
#[source]
source: CodeError,
},
#[error(
"archetypes key {key:?} is no openEHR archetype id ([namespace::]rm_originator-rm_name-rm_entity.concept.vN)"
)]
ArchetypeId {
key: String,
},
#[error("{} keys {first:?} and {second:?} name the same id", .table.name())]
DuplicateKey {
table: Table,
first: String,
second: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MapErrors(Vec<MapError>);
impl MapErrors {
#[must_use]
pub fn errors(&self) -> &[MapError] {
&self.0
}
#[must_use]
pub fn first(&self) -> Option<&MapError> {
self.0.first()
}
#[expect(
clippy::expect_used,
reason = "declare_with returns MapErrors only after pushing a defect, so it is never empty"
)]
fn into_first(self) -> MapError {
self.0
.into_iter()
.next()
.expect("a refused declaration should hold a defect")
}
}
impl fmt::Display for MapErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "the category map has {} defects", self.0.len())?;
for error in &self.0 {
write!(f, "; {error}")?;
}
Ok(())
}
}
impl std::error::Error for MapErrors {}
impl IntoIterator for MapErrors {
type Item = MapError;
type IntoIter = std::vec::IntoIter<MapError>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CategoryMap {
national: BTreeSet<NationalCategory>,
matching: Matching,
templates: BTreeMap<String, (String, Mapping)>,
archetypes: BTreeMap<String, (String, Mapping)>,
digest: Option<String>,
evidence_limit: Option<usize>,
}
impl CategoryMap {
pub fn declare(
national: &[String],
templates: &BTreeMap<String, Declared>,
archetypes: &BTreeMap<String, Declared>,
) -> Result<Self, MapError> {
Self::declare_with(Matching::Exact, national, templates, archetypes)
.map_err(MapErrors::into_first)
}
pub fn declare_with(
matching: Matching,
national: &[String],
templates: &BTreeMap<String, Declared>,
archetypes: &BTreeMap<String, Declared>,
) -> Result<Self, MapErrors> {
let mut errors = Vec::new();
let mut declared = BTreeSet::new();
for code in national {
match code.parse::<NationalCategory>() {
Ok(category) => {
declared.insert(category);
}
Err(source) => errors.push(MapError::National {
code: code.clone(),
source,
}),
}
}
let mut read = |table: Table, keys: &BTreeMap<String, Declared>| {
let mut read: BTreeMap<String, (String, Mapping)> = BTreeMap::new();
for (key, value) in keys {
if table == Table::Archetypes && !is_archetype_id(key) && is_key(key) {
errors.push(MapError::ArchetypeId { key: key.clone() });
continue;
}
match mapping(table, key, value, &declared) {
Ok(mapping) => {
let folded = matching.key(key);
if let Some((first, _)) = read.get(&folded) {
errors.push(MapError::DuplicateKey {
table,
first: first.clone(),
second: key.clone(),
});
} else {
read.insert(folded, (key.clone(), mapping));
}
}
Err(error) => errors.push(error),
}
}
read
};
let templates = read(Table::Templates, templates);
let archetypes = read(Table::Archetypes, archetypes);
if !errors.is_empty() {
return Err(MapErrors(errors));
}
Ok(Self {
national: declared,
matching,
templates,
archetypes,
digest: None,
evidence_limit: None,
})
}
#[must_use]
pub fn with_evidence_limit(mut self, limit: usize) -> Self {
self.evidence_limit = Some(limit);
self
}
#[must_use]
pub fn evidence_limit(&self) -> Option<usize> {
self.evidence_limit
}
#[must_use]
pub fn matching(&self) -> Matching {
self.matching
}
pub fn templates(&self) -> impl Iterator<Item = (&str, &Mapping)> {
self.templates
.values()
.map(|(key, mapping)| (key.as_str(), mapping))
}
pub fn archetypes(&self) -> impl Iterator<Item = (&str, &Mapping)> {
self.archetypes
.values()
.map(|(key, mapping)| (key.as_str(), mapping))
}
pub fn national(&self) -> impl Iterator<Item = &NationalCategory> {
self.national.iter()
}
#[must_use]
pub fn with_digest(mut self, digest: String) -> Self {
self.digest = Some(digest);
self
}
#[must_use]
pub fn digest(&self) -> Option<&str> {
self.digest.as_deref()
}
#[must_use]
pub fn template(&self, template_id: &str) -> Option<&Mapping> {
self.templates
.get(&self.matching.key(template_id))
.map(|(_, mapping)| mapping)
}
#[must_use]
pub fn archetype(&self, archetype_id: &str) -> Option<&Mapping> {
self.archetypes
.get(&self.matching.key(archetype_id))
.map(|(_, mapping)| mapping)
}
#[must_use]
pub fn category(&self, reference: &str) -> Option<Category> {
named(reference, &self.national)
}
pub fn categories(&self) -> impl Iterator<Item = Category> + '_ {
Category::PRIORITY
.into_iter()
.chain(self.national.iter().cloned().map(Category::National))
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.templates.is_empty() && self.archetypes.is_empty()
}
#[must_use]
pub fn canonical(&self) -> String {
let mut text = String::new();
if self.matching == Matching::OpenEhr {
text.push_str("matching\topenehr\n");
}
for national in &self.national {
let _written = writeln!(text, "national\t{}|{}", national.system(), national.code());
}
for (table, keys) in [
(Table::Templates, &self.templates),
(Table::Archetypes, &self.archetypes),
] {
for (key, (_, mapping)) in keys {
let value = match mapping {
Mapping::NoCategory => NONE.to_owned(),
Mapping::Categories(categories) => categories
.iter()
.map(Category::token)
.collect::<Vec<_>>()
.join(","),
};
let _written = writeln!(text, "{}\t{key}\t{value}", table.name());
}
}
text
}
}
fn mapping(
table: Table,
key: &str,
declared: &Declared,
national: &BTreeSet<NationalCategory>,
) -> Result<Mapping, MapError> {
if !is_key(key) {
return Err(MapError::Key(table));
}
let codes = match declared {
Declared::Word(word) if word == NONE => return Ok(Mapping::NoCategory),
Declared::Word(_) => {
return Err(MapError::Word {
table,
key: key.to_owned(),
});
}
Declared::Codes(codes) if codes.is_empty() => {
return Err(MapError::Empty {
table,
key: key.to_owned(),
});
}
Declared::Codes(codes) => codes,
};
codes
.iter()
.map(|code| {
named(code, national).ok_or_else(|| MapError::Unknown {
table,
key: key.to_owned(),
code: code.clone(),
})
})
.collect::<Result<BTreeSet<_>, _>>()
.map(Mapping::Categories)
}
fn is_key(key: &str) -> bool {
!key.is_empty() && !key.chars().any(char::is_control)
}
fn is_archetype_id(id: &str) -> bool {
let alphanumeric = |part: &str| {
let mut characters = part.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_')
};
let extended = |part: &str| {
let mut characters = part.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_' || rest == '-')
};
let (namespace, rest) = match id.split_once("::") {
Some((namespace, rest)) => (Some(namespace), rest),
None => (None, id),
};
if namespace.is_some_and(|namespace| !namespace.split('.').all(extended)) {
return false;
}
let mut sections = rest.splitn(3, '.');
let (Some(entity), Some(concept), Some(version)) =
(sections.next(), sections.next(), sections.next())
else {
return false;
};
let mut entity_parts = entity.split('-');
let entity_ok = matches!(
(entity_parts.next(), entity_parts.next(), entity_parts.next(), entity_parts.next()),
(Some(originator), Some(name), Some(class), None)
if alphanumeric(originator) && alphanumeric(name) && alphanumeric(class)
);
let concept_ok = concept.split('-').all(alphanumeric);
let version_ok = version.strip_prefix('v').is_some_and(is_version_id);
entity_ok && concept_ok && version_ok
}
fn is_version_id(version: &str) -> bool {
let number = |part: &str| {
!part.is_empty()
&& part.bytes().all(|digit| digit.is_ascii_digit())
&& (part == "0" || !part.starts_with('0'))
};
let (numeric, modifier) = match version.split_once('-') {
Some((numeric, modifier)) => (numeric, Some(modifier)),
None => (version, None),
};
let mut count = 0_usize;
for part in numeric.split('.') {
count = count.saturating_add(1);
if !number(part) {
return false;
}
}
let modifier_ok = modifier.is_none_or(|modifier| {
let (kind, build) = match modifier.split_once('.') {
Some((kind, build)) => (kind, Some(build)),
None => (modifier, None),
};
count == 3
&& matches!(kind, "rc" | "alpha" | "beta")
&& build.is_none_or(|build| {
!build.is_empty() && build.bytes().all(|digit| digit.is_ascii_digit())
})
});
(1..=3).contains(&count) && modifier_ok
}
fn named(reference: &str, national: &BTreeSet<NationalCategory>) -> Option<Category> {
let reference = Reference::read(reference);
reference.priority().or_else(|| {
national
.iter()
.find(|declared| reference.names(declared))
.map(|declared| Category::National(declared.clone()))
})
}