Skip to main content

icd11/
lib.rs

1//! ICD-11 from the WHO ICD-API.
2//!
3//! [`api`] walks a local deployment of the ICD-API and caches every entity of
4//! a linearization (or the Foundation) as the JSON the API serves, per
5//! language; [`cache`] reads such a cache back into [`entity::Entity`]
6//! records; [`expression`] parses postcoordination expressions. The system
7//! URIs are THO's (<https://terminology.hl7.org/CodeSystem-ICD11MMS.html>) and
8//! the test cases of the HL7 terminology ecosystem IG name the other two.
9#![doc(test(attr(deny(warnings))))]
10
11pub mod api;
12pub mod cache;
13pub mod entity;
14pub mod expression;
15
16/// The MMS linearization's system URI.
17pub const MMS: &str = "http://id.who.int/icd/release/11/mms";
18/// The ICF linearization's system URI.
19pub const ICF: &str = "http://id.who.int/icd/release/11/icf";
20/// The Foundation's system URI.
21pub const FOUNDATION: &str = "http://id.who.int/icd/entity";
22/// The prefix of the postcoordination axis names.
23pub const SCHEMA: &str = "http://id.who.int/icd/schema/";
24/// The canonical host every URI the API returns starts with.
25pub const CANONICAL: &str = "http://id.who.int";
26
27/// One of the three code systems.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub enum Linearization {
30    /// ICD-11 for Mortality and Morbidity Statistics.
31    Mms,
32    /// The International Classification of Functioning, Disability and Health.
33    Icf,
34    /// The WHO Family of International Classifications Foundation.
35    Foundation,
36}
37
38impl Linearization {
39    /// Every code system, in the order they are built.
40    pub const ALL: [Self; 3] = [Self::Mms, Self::Icf, Self::Foundation];
41
42    /// The system URI.
43    #[must_use]
44    pub const fn system(self) -> &'static str {
45        match self {
46            Self::Mms => MMS,
47            Self::Icf => ICF,
48            Self::Foundation => FOUNDATION,
49        }
50    }
51
52    /// The short name, also the cache directory name.
53    #[must_use]
54    pub const fn name(self) -> &'static str {
55        match self {
56            Self::Mms => "mms",
57            Self::Icf => "icf",
58            Self::Foundation => "entity",
59        }
60    }
61
62    /// The linearization for a short name.
63    #[must_use]
64    pub fn parse(name: &str) -> Option<Self> {
65        Self::ALL.into_iter().find(|l| l.name() == name)
66    }
67
68    /// The API path of the root, for `release` (`2026-01`).
69    #[must_use]
70    pub fn root_path(self, release: &str) -> String {
71        match self {
72            Self::Foundation => String::from("/icd/entity"),
73            other => format!("/icd/release/11/{release}/{}", other.name()),
74        }
75    }
76
77    /// The unversioned URI of the entity `id` (`257068234`, `1363559646/other`).
78    #[must_use]
79    pub fn uri(self, id: &str) -> String {
80        format!("{}/{id}", self.system())
81    }
82
83    /// The versioned URI of the entity `id` in `release`.
84    #[must_use]
85    pub fn versioned_uri(self, release: &str, id: &str) -> String {
86        match self {
87            Self::Foundation => self.uri(id),
88            other => format!(
89                "http://id.who.int/icd/release/11/{release}/{}/{id}",
90                other.name()
91            ),
92        }
93    }
94
95    /// The entity id a URI of this code system names, when it is one:
96    /// `http://id.who.int/icd/release/11/mms/257068234`,
97    /// `.../release/11/2026-01/mms/1363559646/other`, `http://id.who.int/icd/entity/257068234`.
98    #[must_use]
99    pub fn id_of(self, uri: &str) -> Option<String> {
100        let rest = uri.strip_prefix(CANONICAL)?;
101        let tail = match self {
102            Self::Foundation => rest.strip_prefix("/icd/entity/")?,
103            other => {
104                let after = rest.strip_prefix("/icd/release/11/")?;
105                let (head, remainder) = after.split_once('/')?;
106                if head == other.name() {
107                    remainder
108                } else if is_release(head) {
109                    let (name, tail) = remainder.split_once('/')?;
110                    if name != other.name() {
111                        return None;
112                    }
113                    tail
114                } else {
115                    return None;
116                }
117            }
118        };
119        let tail = tail.trim_end_matches('/');
120        let mut parts = tail.split('/');
121        let id = parts.next()?;
122        if id.is_empty() || !id.bytes().all(|b| b.is_ascii_digit()) {
123            return None;
124        }
125        match parts.next() {
126            None => Some(id.to_owned()),
127            Some(residual @ ("other" | "unspecified")) if parts.next().is_none() => {
128                Some(format!("{id}/{residual}"))
129            }
130            Some(_) => None,
131        }
132    }
133
134    /// The key table entry of the entity `id`: the number times four plus
135    /// `1` for `/other` and `2` for `/unspecified`.
136    #[must_use]
137    pub fn key_of(id: &str) -> Option<u64> {
138        let (number, residual) = match id.split_once('/') {
139            None => (id, 0),
140            Some((n, "other")) => (n, 1),
141            Some((n, "unspecified")) => (n, 2),
142            Some(_) => return None,
143        };
144        let number: u64 = number.parse().ok()?;
145        number.checked_mul(4)?.checked_add(residual)
146    }
147}
148
149/// Whether `text` is a release id (`2026-01`).
150#[must_use]
151pub fn is_release(text: &str) -> bool {
152    let bytes = text.as_bytes();
153    bytes.len() == 7
154        && bytes
155            .get(..4)
156            .is_some_and(|y| y.iter().all(u8::is_ascii_digit))
157        && bytes.get(4) == Some(&b'-')
158        && bytes
159            .get(5..)
160            .is_some_and(|m| m.iter().all(u8::is_ascii_digit))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::{Linearization, is_release};
166
167    #[test]
168    fn uris_name_entities_in_both_forms() {
169        let mms = Linearization::Mms;
170        assert_eq!(
171            mms.id_of("http://id.who.int/icd/release/11/mms/257068234")
172                .as_deref(),
173            Some("257068234")
174        );
175        assert_eq!(
176            mms.id_of("http://id.who.int/icd/release/11/2026-01/mms/1363559646/other")
177                .as_deref(),
178            Some("1363559646/other")
179        );
180        assert_eq!(mms.id_of("http://id.who.int/icd/release/11/icf/12"), None);
181        assert_eq!(mms.id_of("http://id.who.int/icd/release/11/mms/abc"), None);
182        assert_eq!(
183            mms.id_of("http://id.who.int/icd/release/11/mms/1/2/3"),
184            None
185        );
186        assert_eq!(
187            Linearization::Foundation
188                .id_of("http://id.who.int/icd/entity/257068234")
189                .as_deref(),
190            Some("257068234")
191        );
192        assert_eq!(
193            Linearization::Foundation.id_of("http://id.who.int/icd/release/11/mms/257068234"),
194            None
195        );
196        assert_eq!(mms.uri("1A"), "http://id.who.int/icd/release/11/mms/1A");
197        assert_eq!(
198            mms.versioned_uri("2026-01", "5"),
199            "http://id.who.int/icd/release/11/2026-01/mms/5"
200        );
201        assert_eq!(
202            Linearization::key_of("1363559646/other"),
203            Some(1_363_559_646 * 4 + 1)
204        );
205        assert_eq!(Linearization::key_of("12/x"), None);
206        assert!(is_release("2026-01"));
207        assert!(!is_release("mms"));
208    }
209}