1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use ::classification::{Class, Classification, PREFERRED, Rubric, collapse};
23
24pub const SYSTEM: &str = "urn:oid:2.16.840.1.113883.2.4.3.120.5.1";
27pub const DUTCH: &str = "nl-NL";
29pub const DESIGNATION_KINDS: [&str; 6] = [PREFERRED, "synonym", "pvo", "pvt", "fsn", "search"];
32pub const SNOMED: &str = "snomed";
34pub const ICD10: &str = "icd10";
36pub const DBC: &str = "dbc";
38pub const ZA: &str = "za";
40pub const ROLE: &str = "role";
42pub const MAPPING: &str = "mapping";
44pub const REPLACED_BY: &str = "replaced-by";
46pub const SPLIT_INTO: &str = "split-into";
48pub const UMBRELLA: &str = "umbrella";
50const NO_END: &str = "2099";
52
53#[derive(Debug, thiserror::Error)]
55pub enum DhdError {
56 #[error("cannot read {path}")]
58 Io {
59 path: PathBuf,
61 #[source]
63 source: std::io::Error,
64 },
65 #[error("no `{table}` table under {root}")]
67 Missing {
68 root: PathBuf,
70 table: &'static str,
72 },
73 #[error("cannot parse {path}")]
75 Csv {
76 path: PathBuf,
78 #[source]
80 source: csv::Error,
81 },
82 #[error("{path} has no column `{column}`")]
84 Column {
85 path: PathBuf,
87 column: &'static str,
89 },
90}
91
92#[derive(Debug, Clone)]
94pub struct Delivery {
95 root: PathBuf,
96 files: Vec<PathBuf>,
97 date: Option<String>,
99 version: Option<String>,
101}
102
103#[derive(Debug, Clone, Default)]
105pub struct Row {
106 fields: BTreeMap<String, String>,
107}
108
109impl Row {
110 #[must_use]
112 pub fn get(&self, column: &str) -> Option<&str> {
113 self.fields
114 .get(&column.to_lowercase())
115 .map(String::as_str)
116 .map(str::trim)
117 .filter(|v| !v.is_empty())
118 }
119
120 #[must_use]
123 pub fn current_at(&self, date: Option<&str>) -> bool {
124 match (self.get("Einddatum"), date) {
125 (Some(end), Some(date)) if !end.starts_with(NO_END) => end >= date,
126 _ => true,
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Thesaurus {
134 pub classification: Classification,
136 pub snomed: Vec<(String, String)>,
138 pub icd10: Vec<(String, Vec<String>)>,
140}
141
142fn version_from_name(name: &str) -> Option<String> {
143 let mut parts = name.split('_');
144 while let Some(part) = parts.next() {
145 if part.ends_with("thesaurus") {
146 let version = parts.next()?;
147 return (version.chars().all(|c| c.is_ascii_digit() || c == '.')
148 && !version.is_empty())
149 .then(|| version.to_owned());
150 }
151 }
152 None
153}
154
155fn date_from_name(name: &str) -> Option<String> {
156 let head = name.split('_').next()?;
157 (head.len() == 8 && head.bytes().all(|b| b.is_ascii_digit())).then(|| head.to_owned())
158}
159
160impl Delivery {
161 pub fn open(root: &Path) -> Result<Self, DhdError> {
168 let mut files = Vec::new();
169 walk(root, &mut files)?;
170 files.sort();
171 let names: Vec<String> = files
172 .iter()
173 .filter_map(|p| {
174 p.file_name()
175 .and_then(std::ffi::OsStr::to_str)
176 .map(str::to_owned)
177 })
178 .collect();
179 let date = names.iter().find_map(|n| date_from_name(n));
180 let version = root
181 .file_name()
182 .and_then(std::ffi::OsStr::to_str)
183 .and_then(version_from_name);
184 let delivery = Self {
185 root: root.to_path_buf(),
186 files,
187 date,
188 version,
189 };
190 delivery.table("ThesaurusConcept")?;
191 Ok(delivery)
192 }
193
194 #[must_use]
196 pub fn date(&self) -> Option<&str> {
197 self.date.as_deref()
198 }
199
200 #[must_use]
202 pub fn version(&self) -> Option<&str> {
203 self.version.as_deref()
204 }
205
206 pub fn table(&self, table: &'static str) -> Result<PathBuf, DhdError> {
212 self.optional(table).ok_or_else(|| DhdError::Missing {
213 root: self.root.clone(),
214 table,
215 })
216 }
217
218 #[must_use]
220 pub fn optional(&self, table: &str) -> Option<PathBuf> {
221 let suffix = format!("_{}.csv", table.to_lowercase());
222 self.files
223 .iter()
224 .find(|p| {
225 p.file_name()
226 .and_then(std::ffi::OsStr::to_str)
227 .is_some_and(|n| n.to_lowercase().ends_with(&suffix))
228 })
229 .cloned()
230 }
231
232 pub fn rows(&self, table: &str) -> Result<Vec<Row>, DhdError> {
238 let Some(path) = self.optional(table) else {
239 return Ok(Vec::new());
240 };
241 read_rows(&path)
242 }
243}
244
245fn walk(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), DhdError> {
246 let io = |source| DhdError::Io {
247 path: root.to_path_buf(),
248 source,
249 };
250 for entry in std::fs::read_dir(root).map_err(io)? {
251 let path = entry.map_err(io)?.path();
252 if path.is_dir() {
253 walk(&path, out)?;
254 } else {
255 out.push(path);
256 }
257 }
258 Ok(())
259}
260
261pub fn read_rows(path: &Path) -> Result<Vec<Row>, DhdError> {
267 let text = std::fs::read_to_string(path).map_err(|source| DhdError::Io {
268 path: path.to_path_buf(),
269 source,
270 })?;
271 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
272 let delimiter = text
273 .lines()
274 .next()
275 .and_then(|h| b",;\t".iter().copied().find(|d| h.contains(char::from(*d))))
276 .unwrap_or(b',');
277 let mut reader = csv::ReaderBuilder::new()
278 .delimiter(delimiter)
279 .flexible(true)
280 .from_reader(text.as_bytes());
281 let csv_error = |source| DhdError::Csv {
282 path: path.to_path_buf(),
283 source,
284 };
285 let headers: Vec<String> = reader
286 .headers()
287 .map_err(csv_error)?
288 .iter()
289 .map(|h| h.trim().to_lowercase())
290 .collect();
291 let mut rows = Vec::new();
292 for record in reader.records() {
293 let record = record.map_err(csv_error)?;
294 let mut fields = BTreeMap::new();
295 for (i, header) in headers.iter().enumerate() {
296 if let Some(value) = record.get(i) {
297 fields.insert(header.clone(), value.to_owned());
298 }
299 }
300 rows.push(Row { fields });
301 }
302 Ok(rows)
303}
304
305fn term_kind(type_term: &str) -> Option<&'static str> {
307 match type_term.to_lowercase().as_str() {
308 "voorkeursterm" => Some(PREFERRED),
309 "synoniem" => Some("synonym"),
310 "pvo" => Some("pvo"),
311 "pvt" => Some("pvt"),
312 "fsn" => Some("fsn"),
313 "zoekterm" => Some("search"),
314 _ => None,
315 }
316}
317
318fn required<'a>(row: &'a Row, column: &'static str, path: &Path) -> Result<&'a str, DhdError> {
319 row.get(column).ok_or_else(|| DhdError::Column {
320 path: path.to_path_buf(),
321 column,
322 })
323}
324
325struct Concepts {
328 classes: BTreeMap<String, Class>,
329 kinds: Vec<String>,
330}
331
332type Icd10Derivations = BTreeMap<String, Vec<(u32, String)>>;
334
335fn read_concepts(delivery: &Delivery, date: Option<&str>) -> Result<Concepts, DhdError> {
342 let concept_table = delivery.table("ThesaurusConcept")?;
343 let mut classes: BTreeMap<String, Class> = BTreeMap::new();
344 let mut kinds: Vec<String> = Vec::new();
345 for row in read_rows(&concept_table)? {
346 let id = required(&row, "ConceptID", &concept_table)?.to_owned();
347 let kind = required(&row, "TypeConcept", &concept_table)?.to_owned();
348 if !kinds.contains(&kind) {
349 kinds.push(kind.clone());
350 }
351 let mut rubrics = Vec::new();
352 for (column, name) in [
353 ("Complicatie", "complication"),
354 ("GebruiktImplantaat", "implant"),
355 ("Gradatie", "grading"),
356 ("Lateraliteit", "laterality"),
357 ("LOINCCode", "loinc"),
358 ] {
359 if let Some(value) = row.get(column) {
360 rubrics.push(Rubric {
361 kind: name.to_owned(),
362 language: DUTCH.to_owned(),
363 text: value.to_owned(),
364 });
365 }
366 }
367 classes.insert(
368 id.clone(),
369 Class {
370 code: id,
371 kind,
372 parent: None,
373 usage: None,
374 valid: None,
375 active: row.current_at(date),
376 rubrics,
377 },
378 );
379 }
380 Ok(Concepts { classes, kinds })
381}
382
383fn read_terms(
390 delivery: &Delivery,
391 date: Option<&str>,
392 classes: &mut BTreeMap<String, Class>,
393) -> Result<BTreeMap<String, String>, DhdError> {
394 let mut snomed: BTreeMap<String, String> = BTreeMap::new();
395 for row in delivery.rows("ThesaurusTerm")? {
396 if !row.current_at(date) {
397 continue;
398 }
399 let (Some(id), Some(text), Some(type_term)) = (
400 row.get("ConceptID"),
401 row.get("Omschrijving"),
402 row.get("TypeTerm"),
403 ) else {
404 continue;
405 };
406 let Some(class) = classes.get_mut(id) else {
407 continue;
408 };
409 let Some(kind) = term_kind(type_term) else {
410 continue;
411 };
412 class.rubrics.push(Rubric {
413 kind: kind.to_owned(),
414 language: row.get("TaalCode").unwrap_or(DUTCH).to_owned(),
415 text: collapse(text),
416 });
417 if kind == "fsn"
418 && let Some(sctid) = row.get("SnomedID")
419 {
420 snomed
421 .entry(id.to_owned())
422 .or_insert_with(|| sctid.to_owned());
423 if !class
424 .rubrics
425 .iter()
426 .any(|r| r.kind == SNOMED && r.text == sctid)
427 {
428 class.rubrics.push(Rubric {
429 kind: SNOMED.to_owned(),
430 language: DUTCH.to_owned(),
431 text: sctid.to_owned(),
432 });
433 }
434 }
435 }
436 Ok(snomed)
437}
438
439fn read_relations(
453 delivery: &Delivery,
454 classes: &mut BTreeMap<String, Class>,
455) -> Result<(), DhdError> {
456 for row in delivery.rows("ThesaurusConceptRelaties")? {
457 let (Some(first), Some(second), Some(kind)) = (
458 row.get("ConceptID1"),
459 row.get("ConceptID2"),
460 row.get("TypeRelatie"),
461 ) else {
462 continue;
463 };
464 let name = match kind.to_lowercase().as_str() {
465 "vervanging" => REPLACED_BY,
466 "splitsing" => SPLIT_INTO,
467 _ => continue,
468 };
469 if let Some(class) = classes.get_mut(first) {
470 class.rubrics.push(Rubric {
471 kind: name.to_owned(),
472 language: DUTCH.to_owned(),
473 text: second.to_owned(),
474 });
475 }
476 }
477 Ok(())
478}
479
480fn read_umbrella_terms(
486 delivery: &Delivery,
487 date: Option<&str>,
488 classes: &mut BTreeMap<String, Class>,
489) -> Result<(), DhdError> {
490 for row in delivery.rows("Parapluterm")? {
491 if !row.current_at(date) {
492 continue;
493 }
494 if let (Some(umbrella), Some(member)) = (row.get("ConceptID1"), row.get("ConceptID2"))
495 && let Some(class) = classes.get_mut(member)
496 {
497 class.rubrics.push(Rubric {
498 kind: UMBRELLA.to_owned(),
499 language: DUTCH.to_owned(),
500 text: umbrella.to_owned(),
501 });
502 }
503 }
504 Ok(())
505}
506
507fn read_roles(
513 delivery: &Delivery,
514 date: Option<&str>,
515 classes: &mut BTreeMap<String, Class>,
516) -> Result<(), DhdError> {
517 for row in delivery.rows("ThesaurusConceptRol")? {
518 if !row.current_at(date) {
519 continue;
520 }
521 if let (Some(id), Some(name), Some(value)) = (
522 row.get("ConceptID"),
523 row.get("Rolnaam"),
524 row.get("Rolwaarde"),
525 ) && let Some(class) = classes.get_mut(id)
526 {
527 let group = row
528 .get("SpecialismeGroepCode")
529 .map(|g| format!(" [{g}]"))
530 .unwrap_or_default();
531 class.rubrics.push(Rubric {
532 kind: ROLE.to_owned(),
533 language: DUTCH.to_owned(),
534 text: format!("{name}={value}{group}"),
535 });
536 }
537 }
538 Ok(())
539}
540
541fn read_icd10(
548 delivery: &Delivery,
549 date: Option<&str>,
550 classes: &mut BTreeMap<String, Class>,
551) -> Result<Icd10Derivations, DhdError> {
552 let mut icd10: Icd10Derivations = BTreeMap::new();
553 for row in delivery.rows("AfleidingICD10")? {
554 if !row.current_at(date) {
555 continue;
556 }
557 if let (Some(id), Some(code)) = (row.get("ConceptID"), row.get("ICD10"))
558 && let Some(class) = classes.get_mut(id)
559 {
560 let order = row
561 .get("Volgnummer")
562 .and_then(|v| v.parse().ok())
563 .unwrap_or(u32::MAX);
564 icd10
565 .entry(id.to_owned())
566 .or_default()
567 .push((order, code.to_owned()));
568 class.rubrics.push(Rubric {
569 kind: ICD10.to_owned(),
570 language: DUTCH.to_owned(),
571 text: code.to_owned(),
572 });
573 }
574 }
575 Ok(icd10)
576}
577
578fn read_derivations(
585 delivery: &Delivery,
586 date: Option<&str>,
587 classes: &mut BTreeMap<String, Class>,
588) -> Result<(), DhdError> {
589 for (table, kind, code_column) in [
590 ("AfleidingDBC", DBC, "DBC_ID"),
591 ("AfleidingZA", ZA, "ZA_Code"),
592 ] {
593 for row in delivery.rows(table)? {
594 if !row.current_at(date) {
595 continue;
596 }
597 if let (Some(id), Some(code)) = (row.get("ConceptID"), row.get(code_column))
598 && let Some(class) = classes.get_mut(id)
599 {
600 let specialism = row
601 .get("SpecialismeCode")
602 .map(|s| format!(" ({s})"))
603 .unwrap_or_default();
604 class.rubrics.push(Rubric {
605 kind: kind.to_owned(),
606 language: DUTCH.to_owned(),
607 text: format!("{code}{specialism}"),
608 });
609 }
610 }
611 }
612 Ok(())
613}
614
615fn read_mappings(
621 delivery: &Delivery,
622 date: Option<&str>,
623 classes: &mut BTreeMap<String, Class>,
624) -> Result<(), DhdError> {
625 for row in delivery.rows("CodeMapping")? {
626 if !row.current_at(date) {
627 continue;
628 }
629 if let (Some(id), Some(system), Some(code)) = (
630 row.get("ConceptID"),
631 row.get("Codestelsel"),
632 row.get("Code"),
633 ) && let Some(class) = classes.get_mut(id)
634 {
635 class.rubrics.push(Rubric {
636 kind: MAPPING.to_owned(),
637 language: DUTCH.to_owned(),
638 text: format!("{system}: {code}"),
639 });
640 }
641 }
642 Ok(())
643}
644
645pub fn read(root: &Path, version: Option<&str>) -> Result<Thesaurus, DhdError> {
656 let delivery = Delivery::open(root)?;
657 let date = delivery.date();
658 let Concepts { mut classes, kinds } = read_concepts(&delivery, date)?;
659 let snomed = read_terms(&delivery, date, &mut classes)?;
660 read_relations(&delivery, &mut classes)?;
661 read_umbrella_terms(&delivery, date, &mut classes)?;
662 read_roles(&delivery, date, &mut classes)?;
663 let icd10 = read_icd10(&delivery, date, &mut classes)?;
664 read_derivations(&delivery, date, &mut classes)?;
665 read_mappings(&delivery, date, &mut classes)?;
666 let icd10 = icd10
667 .into_iter()
668 .map(|(id, mut codes)| {
669 codes.sort();
670 (id, codes.into_iter().map(|(_, c)| c).collect())
671 })
672 .collect();
673 let version = version
674 .map(str::to_owned)
675 .or_else(|| delivery.version().map(str::to_owned))
676 .or_else(|| date.map(str::to_owned));
677 let procedures = kinds.iter().any(|k| k.eq_ignore_ascii_case("Verrichting"));
678 Ok(Thesaurus {
679 classification: Classification {
680 name: String::from(if procedures {
681 "Verrichtingenthesaurus"
682 } else {
683 "Diagnosethesaurus"
684 }),
685 title: String::from(if procedures {
686 "DHD Verrichtingenthesaurus"
687 } else {
688 "DHD Diagnosethesaurus"
689 }),
690 version,
691 language: String::from("nl"),
692 kinds,
693 classes: classes.into_values().collect(),
694 designation_kinds: DESIGNATION_KINDS.iter().map(|k| (*k).to_owned()).collect(),
695 hierarchy: None,
696 },
697 snomed: snomed.into_iter().collect(),
698 icd10,
699 })
700}
701
702#[cfg(test)]
703mod tests {
704 use super::{Row, date_from_name, term_kind, version_from_name};
705
706 #[test]
707 fn names_carry_the_date_and_the_version() {
708 assert_eq!(
709 version_from_name("20170824_113250_Diagnosethesaurus_2.27_uitleverformaat_5.0")
710 .as_deref(),
711 Some("2.27")
712 );
713 assert_eq!(version_from_name("random"), None);
714 assert_eq!(
715 date_from_name("20221212_140720_uitleverformaat5.0_ThesaurusConcept.csv").as_deref(),
716 Some("20221212")
717 );
718 assert_eq!(term_kind("Voorkeursterm"), Some("preferred"));
719 assert_eq!(term_kind("Zoekterm"), Some("search"));
720 assert_eq!(term_kind("other"), None);
721 let mut row = Row::default();
722 row.fields
723 .insert(String::from("einddatum"), String::from("20990101"));
724 assert!(row.current_at(Some("20250101")));
725 row.fields
726 .insert(String::from("einddatum"), String::from("20240101"));
727 assert!(!row.current_at(Some("20250101")));
728 assert!(row.current_at(None));
729 }
730}