Skip to main content

dhd_thesaurus/
lib.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The DHD Diagnosethesaurus and Verrichtingenthesaurus deliveries
5//! ("Uitleverformaat 5.0", <https://www.dhd.nl/assets/uploads/Uitleverformaat-Thesauri-5.0-v1.0.pdf>).
6//!
7//! A delivery is a set of UTF-8 CSV tables. `ThesaurusConcept` gives the
8//! concepts (`ConceptID`, `TypeConcept`, the flags, the validity dates, a
9//! LOINC code for laboratory procedures), `ThesaurusTerm` their terms by type
10//! and language (the preferred term, synonyms, the patient-friendly terms, the
11//! fully specified names with their SNOMED CT identifier, search terms),
12//! `ThesaurusConceptRelaties` replacements and splits, `ThesaurusConceptRol`
13//! the roles per specialism group, `Parapluterm` umbrella terms, and the
14//! `Afleiding*` and `CodeMapping` tables the derivations to ICD-10, DBC, ZA,
15//! and other code systems. The reader turns a delivery into a flat
16//! [`Classification`] (no hierarchy; the relations are properties) and the
17//! SNOMED CT and ICD-10 links into pairs the build writes as concept maps.
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use ::classification::{Class, Classification, PREFERRED, Rubric, collapse};
23
24/// The system URI of the thesauri: the DHD concept identifier OID
25/// (<https://www.dhd.nl/producten-diensten/diagnosethesaurus>).
26pub const SYSTEM: &str = "urn:oid:2.16.840.1.113883.2.4.3.120.5.1";
27/// The Dutch language tag the terms carry.
28pub const DUTCH: &str = "nl-NL";
29/// The rubric kinds that are designations, the preferred term first
30/// (`TypeTerm` values of the delivery, in the reader's spelling).
31pub const DESIGNATION_KINDS: [&str; 6] = [PREFERRED, "synonym", "pvo", "pvt", "fsn", "search"];
32/// The property that carries the SNOMED CT identifier of a concept.
33pub const SNOMED: &str = "snomed";
34/// The property that carries an ICD-10 derivation.
35pub const ICD10: &str = "icd10";
36/// The property that carries a DBC derivation (`DBC_ID (specialism)`).
37pub const DBC: &str = "dbc";
38/// The property that carries a ZA derivation.
39pub const ZA: &str = "za";
40/// The property that carries a role (`name=value [group]`).
41pub const ROLE: &str = "role";
42/// The property that carries a code mapping (`system: code`).
43pub const MAPPING: &str = "mapping";
44/// The property naming the concepts that replace an ended one.
45pub const REPLACED_BY: &str = "replaced-by";
46/// The property naming the concepts an ended one was split into.
47pub const SPLIT_INTO: &str = "split-into";
48/// The property naming the umbrella term a concept falls under.
49pub const UMBRELLA: &str = "umbrella";
50/// The end date that means "no end" in a delivery.
51const NO_END: &str = "2099";
52
53/// A failure to read a delivery.
54#[derive(Debug, thiserror::Error)]
55pub enum DhdError {
56    /// A directory or file does not read.
57    #[error("cannot read {path}")]
58    Io {
59        /// The path.
60        path: PathBuf,
61        /// The cause.
62        #[source]
63        source: std::io::Error,
64    },
65    /// A table the reader needs is not in the delivery.
66    #[error("no `{table}` table under {root}")]
67    Missing {
68        /// The delivery directory.
69        root: PathBuf,
70        /// The table name.
71        table: &'static str,
72    },
73    /// A table does not parse.
74    #[error("cannot parse {path}")]
75    Csv {
76        /// The path.
77        path: PathBuf,
78        /// The cause.
79        #[source]
80        source: csv::Error,
81    },
82    /// A table lacks a column.
83    #[error("{path} has no column `{column}`")]
84    Column {
85        /// The path.
86        path: PathBuf,
87        /// The column.
88        column: &'static str,
89    },
90}
91
92/// A delivery: its tables, found by name under a directory.
93#[derive(Debug, Clone)]
94pub struct Delivery {
95    root: PathBuf,
96    files: Vec<PathBuf>,
97    /// The date the file names carry (`YYYYMMDD`), when they do.
98    date: Option<String>,
99    /// The thesaurus version the zip or directory name carries (`2.27`).
100    version: Option<String>,
101}
102
103/// One row of a table, by column name.
104#[derive(Debug, Clone, Default)]
105pub struct Row {
106    fields: BTreeMap<String, String>,
107}
108
109impl Row {
110    /// The trimmed value of `column`, `None` when absent or empty.
111    #[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    /// Whether the row is current at `date`: its `Einddatum` is absent, in
121    /// 2099, or not before `date`.
122    #[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/// What a delivery yields: the thesaurus and its outward links.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Thesaurus {
134    /// The concepts with their terms and properties.
135    pub classification: Classification,
136    /// `(concept id, SNOMED CT id)` from the fully specified names.
137    pub snomed: Vec<(String, String)>,
138    /// `(concept id, ICD-10 codes in order)` from the ICD-10 derivations.
139    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    /// Opens the delivery under `root` (the unpacked zip).
162    ///
163    /// # Errors
164    ///
165    /// Returns [`DhdError`] when the directory does not read or holds no
166    /// `ThesaurusConcept` table.
167    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    /// The delivery date the file names carry.
195    #[must_use]
196    pub fn date(&self) -> Option<&str> {
197        self.date.as_deref()
198    }
199
200    /// The thesaurus version the directory name carries.
201    #[must_use]
202    pub fn version(&self) -> Option<&str> {
203        self.version.as_deref()
204    }
205
206    /// The file of `table` (`…_ThesaurusConcept.csv`).
207    ///
208    /// # Errors
209    ///
210    /// Returns [`DhdError::Missing`] when the delivery has no such table.
211    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    /// The file of `table` when the delivery has it.
219    #[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    /// The rows of `table`, or none when the delivery lacks it.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`DhdError`] when the file does not read or parse.
237    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
261/// Reads a table: UTF-8, a header row, quoted fields.
262///
263/// # Errors
264///
265/// Returns [`DhdError`] when the file does not read or parse.
266pub 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
305/// The rubric kind of a `TypeTerm` value.
306fn 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
325/// The concepts of a delivery: the classes by identifier and the concept
326/// kinds in the order the table names them.
327struct Concepts {
328    classes: BTreeMap<String, Class>,
329    kinds: Vec<String>,
330}
331
332/// The ICD-10 derivations of a concept: the codes with their order number.
333type Icd10Derivations = BTreeMap<String, Vec<(u32, String)>>;
334
335/// Reads `ThesaurusConcept`: one class per concept, with the flag columns as
336/// rubrics and the validity as the active flag.
337///
338/// # Errors
339///
340/// Returns [`DhdError`] when the table does not read or lacks a column.
341fn 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
383/// Reads `ThesaurusTerm` into the classes and returns the SNOMED CT
384/// identifier of every fully specified name that carries one.
385///
386/// # Errors
387///
388/// Returns [`DhdError`] when the table does not read.
389fn 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
439/// Reads `ThesaurusConceptRelaties`: the replacements and splits of the ended
440/// concepts, as properties of the concept they leave.
441///
442/// The table is read whole, where every other table drops a row that is not
443/// current at the delivery date. A relation exists because the concept it
444/// leaves has ended, and that concept is served (inactive), so filtering the
445/// relation by the same date would drop exactly the successor a client of the
446/// inactive concept has to follow. No published DHD documentation states this,
447/// so it is our own design, pinned by a test (#269).
448///
449/// # Errors
450///
451/// Returns [`DhdError`] when the table does not read.
452fn 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
480/// Reads `Parapluterm`: the umbrella term a concept falls under.
481///
482/// # Errors
483///
484/// Returns [`DhdError`] when the table does not read.
485fn 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
507/// Reads `ThesaurusConceptRol`: the roles per specialism group.
508///
509/// # Errors
510///
511/// Returns [`DhdError`] when the table does not read.
512fn 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
541/// Reads `AfleidingICD10` into the classes and returns the derivations with
542/// their order numbers.
543///
544/// # Errors
545///
546/// Returns [`DhdError`] when the table does not read.
547fn 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
578/// Reads `AfleidingDBC` and `AfleidingZA`: the derivations that carry a
579/// specialism code.
580///
581/// # Errors
582///
583/// Returns [`DhdError`] when a table does not read.
584fn 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
615/// Reads `CodeMapping`: the codes of other systems a concept maps to.
616///
617/// # Errors
618///
619/// Returns [`DhdError`] when the table does not read.
620fn 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
645/// Reads the thesaurus of the delivery under `root`.
646///
647/// Concepts and terms ended before the delivery date are skipped as terms
648/// and served inactive as concepts; the relations and derivations become
649/// properties; the SNOMED CT identifiers of the fully specified names and the
650/// ICD-10 derivations are also returned as pairs for the concept maps.
651///
652/// # Errors
653///
654/// Returns [`DhdError`] when a table does not read or lacks a column.
655pub 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}