diff --git a/src/claml.rs b/src/claml.rs index 4b0fd9d..a763a5c 100644 --- a/src/claml.rs +++ b/src/claml.rs @@ -1 +1,157 @@ -// implemented in a later task +use crate::model::{AppError, IcdMeta}; +use quick_xml::events::Event; +use quick_xml::reader::Reader; +use std::collections::HashMap; + +/// Convert a Roman numeral chapter code (I..XXII) to a zero-padded decimal string ("01".."22"). +/// Returns `None` for unrecognised strings. +fn roman_to_padded(roman: &str) -> Option { + let n: u32 = match roman { + "I" => 1, "II" => 2, "III" => 3, "IV" => 4, "V" => 5, + "VI" => 6, "VII" => 7, "VIII" => 8, "IX" => 9, "X" => 10, + "XI" => 11, "XII" => 12, "XIII" => 13, "XIV" => 14, "XV" => 15, + "XVI" => 16, "XVII" => 17, "XVIII" => 18, "XIX" => 19, "XX" => 20, + "XXI" => 21, "XXII" => 22, + _ => return None, + }; + Some(format!("{n:02}")) +} + +/// Stream-parse the ClaML XML into a map keyed by normalised ICD code. +/// +/// The file is flat: `` elements close before their +/// associated `` siblings begin. We therefore track +/// the most-recently-seen chapter and assign it to every category that +/// follows, until the next chapter appears. +pub fn load(path: &str) -> Result, AppError> { + let mut reader = Reader::from_file(path) + .map_err(|e| AppError::Io(format!("{path}: {e}")))?; + reader.config_mut().trim_text(true); + + let mut map: HashMap = HashMap::new(); + let mut buf = Vec::new(); + + // Active category being built; None when processing non-category Classes. + let mut cur: Option = None; + let mut cur_kind = String::new(); + + // The chapter we most recently entered (zero-padded Arabic, e.g. "01"). + let mut current_chapter = String::new(); + + let mut in_preferred = false; + + loop { + match reader.read_event_into(&mut buf) { + Err(e) => return Err(AppError::Parse(format!("claml: {e}"))), + Ok(Event::Eof) => break, + + Ok(Event::Start(ref t)) | Ok(Event::Empty(ref t)) => { + let name = t.name(); + match name.as_ref() { + b"Class" => { + let mut code = String::new(); + cur_kind.clear(); + for a in t.attributes().flatten() { + match a.key.as_ref() { + b"code" => code = String::from_utf8_lossy(&a.value).into_owned(), + b"kind" => cur_kind = String::from_utf8_lossy(&a.value).into_owned(), + _ => {} + } + } + if cur_kind == "chapter" { + // Update the running chapter for all subsequent categories. + if let Some(padded) = roman_to_padded(&code) { + current_chapter = padded; + } + cur = None; + } else if cur_kind == "category" { + let m = IcdMeta { + code, + chapter: current_chapter.clone(), + ..IcdMeta::default() + }; + cur = Some(m); + } else { + cur = None; + } + } + b"SuperClass" => { + if let Some(m) = cur.as_mut() { + for a in t.attributes().flatten() { + if a.key.as_ref() == b"code" { + let sup = String::from_utf8_lossy(&a.value); + if m.group.is_empty() { + m.group = sup.into_owned(); + } + } + } + } + } + b"Meta" => { + if let Some(m) = cur.as_mut() { + let mut k = String::new(); + let mut v = String::new(); + for a in t.attributes().flatten() { + match a.key.as_ref() { + b"name" => k = String::from_utf8_lossy(&a.value).into_owned(), + b"value" => v = String::from_utf8_lossy(&a.value).into_owned(), + _ => {} + } + } + match k.as_str() { + "Para295" => m.para295 = v, + "Para301" => m.para301 = v, + "Exotic" => m.exotic = v == "J", + "Infectious" => m.ifsg = v == "J", + "Content" => m.content = v == "J", + // A `chapter` Meta overrides the stack-derived value if present. + "chapter" => m.chapter = v, + _ => {} + } + } + } + b"Rubric" => { + let mut rubric_kind = String::new(); + for a in t.attributes().flatten() { + if a.key.as_ref() == b"kind" { + rubric_kind = String::from_utf8_lossy(&a.value).into_owned(); + } + } + in_preferred = rubric_kind == "preferred"; + } + _ => {} + } + } + + Ok(Event::Text(txt)) => { + if in_preferred { + if let Some(m) = cur.as_mut() { + if m.description.is_empty() { + m.description = txt.unescape().unwrap_or_default().into_owned(); + } + } + } + } + + Ok(Event::End(ref t)) => match t.name().as_ref() { + b"Rubric" => in_preferred = false, + b"Class" => { + if let Some(m) = cur.take() { + if !m.code.is_empty() { + map.insert(m.code.clone(), m); + } + } + } + _ => {} + }, + + _ => {} + } + buf.clear(); + } + + if map.is_empty() { + return Err(AppError::Parse(format!("no ClaML categories in {path}"))); + } + Ok(map) +} diff --git a/tests/claml_tests.rs b/tests/claml_tests.rs new file mode 100644 index 0000000..30b428f --- /dev/null +++ b/tests/claml_tests.rs @@ -0,0 +1,13 @@ +use alpha_id::claml; + +#[test] +fn parses_real_claml_meta_and_titles() { + let map = claml::load("icd-claml/Klassifikationsdateien/icd10gm2026syst_claml_20250912.xml").unwrap(); + let a010 = map.get("A01.0").expect("A01.0 present"); + assert_eq!(a010.para295, "P"); // billable primary + assert_eq!(a010.chapter, "01"); + assert!(a010.description.contains("Typhus abdominalis")); + let a01 = map.get("A01").expect("A01 present"); + assert_eq!(a01.para295, "V"); // 3-digit non-codable + assert!(map.len() > 10_000); +}