Skip to main content

rxnorm_rrf/
lib.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! `RxNorm` Rich Release Format (RRF) reading.
5//!
6//! Locates the files of an unpacked release (`RXNCONSO.RRF`, `RXNREL.RRF`,
7//! `RXNSAT.RRF`, and `RXNSTY.RRF` when the release has it) and reads them
8//! streaming into typed rows by the documented column positions
9//! (<https://www.nlm.nih.gov/research/umls/rxnorm/docs/techdoc.html>).
10//! The version is the release date as the file names carry it (`09082026`),
11//! the form the FHIR `RxNorm` page asks for (<https://hl7.org/fhir/R4B/rxnorm.html>).
12#![doc(test(attr(deny(warnings))))]
13
14pub mod row;
15
16use std::fs::File;
17use std::path::{Path, PathBuf};
18
19use crate::row::{Atom, Attribute, FromRecord, Relationship, SemanticType};
20
21/// A failure to read a release.
22#[derive(Debug, thiserror::Error)]
23pub enum RrfError {
24    /// A directory or file cannot be read.
25    #[error("cannot read {path}")]
26    Io {
27        /// The path.
28        path: PathBuf,
29        /// The cause.
30        #[source]
31        source: std::io::Error,
32    },
33    /// A file the reader needs is not in the release.
34    #[error("the release under {root} has no {name}")]
35    Missing {
36        /// The release directory.
37        root: PathBuf,
38        /// The file name looked for.
39        name: &'static str,
40    },
41    /// A row does not parse.
42    #[error("cannot parse {path}")]
43    Csv {
44        /// The path.
45        path: PathBuf,
46        /// The cause.
47        #[source]
48        source: csv::Error,
49    },
50    /// A row has fewer columns than the layout.
51    #[error("{path}:{line}: {columns} columns, the layout has {expected}")]
52    Columns {
53        /// The path.
54        path: PathBuf,
55        /// The 1-based line.
56        line: u64,
57        /// The columns found.
58        columns: usize,
59        /// The columns expected.
60        expected: usize,
61    },
62    /// A field is not UTF-8.
63    #[error("{path}:{line}: a field is not UTF-8")]
64    Utf8 {
65        /// The path.
66        path: PathBuf,
67        /// The 1-based line.
68        line: u64,
69        /// The cause.
70        #[source]
71        source: std::str::Utf8Error,
72    },
73    /// An identifier is not a number.
74    #[error("{path}:{line}: `{value}` is not an identifier")]
75    Identifier {
76        /// The path.
77        path: PathBuf,
78        /// The 1-based line.
79        line: u64,
80        /// The value.
81        value: String,
82        /// The cause.
83        #[source]
84        source: std::num::ParseIntError,
85    },
86}
87
88/// The concept and atom names, `RXNCONSO.RRF`.
89pub const CONSO: &str = "RXNCONSO.RRF";
90/// The relationships, `RXNREL.RRF`.
91pub const REL: &str = "RXNREL.RRF";
92/// The attributes, `RXNSAT.RRF`.
93pub const SAT: &str = "RXNSAT.RRF";
94/// The semantic types, `RXNSTY.RRF` (the full release only).
95pub const STY: &str = "RXNSTY.RRF";
96
97/// An unpacked release, its files located by name at any depth.
98#[derive(Debug, Clone)]
99pub struct Release {
100    root: PathBuf,
101    files: Vec<PathBuf>,
102}
103
104impl Release {
105    /// Opens the release under `root`.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`RrfError`] when the directory does not read or has no
110    /// `RXNCONSO.RRF`.
111    pub fn open(root: &Path) -> Result<Self, RrfError> {
112        let mut files = Vec::new();
113        walk(root, &mut files)?;
114        files.sort();
115        let release = Self {
116            root: root.to_path_buf(),
117            files,
118        };
119        release.file(CONSO)?;
120        Ok(release)
121    }
122
123    /// The release directory.
124    #[must_use]
125    pub fn root(&self) -> &Path {
126        &self.root
127    }
128
129    /// The path of the file `name`, compared without case.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`RrfError::Missing`] when the release has no such file.
134    pub fn file(&self, name: &'static str) -> Result<PathBuf, RrfError> {
135        self.optional(name).ok_or_else(|| RrfError::Missing {
136            root: self.root.clone(),
137            name,
138        })
139    }
140
141    /// The path of the file `name` when the release has it.
142    #[must_use]
143    pub fn optional(&self, name: &str) -> Option<PathBuf> {
144        self.files
145            .iter()
146            .find(|p| {
147                p.file_name()
148                    .and_then(std::ffi::OsStr::to_str)
149                    .is_some_and(|f| f.eq_ignore_ascii_case(name))
150            })
151            .cloned()
152    }
153
154    /// The release date from a `Readme_*_MMDDYYYY.txt` in the release, else
155    /// from eight digits ending the directory name (`RxNorm_full_09082026`).
156    #[must_use]
157    pub fn version(&self) -> Option<String> {
158        let from_readme = self.files.iter().find_map(|p| {
159            let name = p.file_name()?.to_str()?;
160            let stem = name.strip_suffix(".txt")?;
161            (stem.starts_with("Readme"))
162                .then(|| date_suffix(stem))
163                .flatten()
164        });
165        from_readme.or_else(|| {
166            self.root
167                .file_name()
168                .and_then(std::ffi::OsStr::to_str)
169                .and_then(date_suffix)
170        })
171    }
172
173    /// The atoms.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`RrfError`] when the file is missing or does not open.
178    pub fn atoms(&self) -> Result<Rows<Atom>, RrfError> {
179        Rows::open(self.file(CONSO)?)
180    }
181
182    /// The relationships.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`RrfError`] when the file is missing or does not open.
187    pub fn relationships(&self) -> Result<Rows<Relationship>, RrfError> {
188        Rows::open(self.file(REL)?)
189    }
190
191    /// The attributes.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`RrfError`] when the file is missing or does not open.
196    pub fn attributes(&self) -> Result<Rows<Attribute>, RrfError> {
197        Rows::open(self.file(SAT)?)
198    }
199
200    /// The semantic types, when the release has them.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`RrfError`] when the file does not open.
205    pub fn semantic_types(&self) -> Result<Option<Rows<SemanticType>>, RrfError> {
206        self.optional(STY).map(Rows::open).transpose()
207    }
208}
209
210/// Eight digits ending `name` (after the last `_`), as the version.
211fn date_suffix(name: &str) -> Option<String> {
212    let (_, tail) = name.rsplit_once('_')?;
213    (tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit())).then(|| tail.to_owned())
214}
215
216fn walk(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), RrfError> {
217    let io = |source| RrfError::Io {
218        path: root.to_path_buf(),
219        source,
220    };
221    for entry in std::fs::read_dir(root).map_err(io)? {
222        let path = entry.map_err(io)?.path();
223        if path.is_dir() {
224            walk(&path, out)?;
225        } else {
226            out.push(path);
227        }
228    }
229    Ok(())
230}
231
232/// The rows of one file, streamed.
233pub struct Rows<T> {
234    reader: csv::Reader<File>,
235    record: csv::ByteRecord,
236    path: PathBuf,
237    line: u64,
238    row: std::marker::PhantomData<T>,
239}
240
241impl<T> std::fmt::Debug for Rows<T> {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.debug_struct("Rows")
244            .field("path", &self.path)
245            .field("line", &self.line)
246            .finish_non_exhaustive()
247    }
248}
249
250impl<T: FromRecord> Rows<T> {
251    fn open(path: PathBuf) -> Result<Self, RrfError> {
252        let file = File::open(&path).map_err(|source| RrfError::Io {
253            path: path.clone(),
254            source,
255        })?;
256        let reader = csv::ReaderBuilder::new()
257            .delimiter(b'|')
258            .has_headers(false)
259            .flexible(true)
260            .quoting(false)
261            .from_reader(file);
262        Ok(Self {
263            reader,
264            record: csv::ByteRecord::new(),
265            path,
266            line: 0,
267            row: std::marker::PhantomData,
268        })
269    }
270}
271
272impl<T: FromRecord> Iterator for Rows<T> {
273    type Item = Result<T, RrfError>;
274
275    fn next(&mut self) -> Option<Self::Item> {
276        match self.reader.read_byte_record(&mut self.record) {
277            Ok(false) => None,
278            Ok(true) => {
279                self.line = self.line.saturating_add(1);
280                Some(T::from_record(&self.record, &self.path, self.line))
281            }
282            Err(source) => Some(Err(RrfError::Csv {
283                path: self.path.clone(),
284                source,
285            })),
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::date_suffix;
293
294    #[test]
295    fn the_version_is_the_eight_digit_date() {
296        assert_eq!(
297            date_suffix("Readme_Full_Prescribe_09082026").as_deref(),
298            Some("09082026")
299        );
300        assert_eq!(
301            date_suffix("RxNorm_full_09082026").as_deref(),
302            Some("09082026")
303        );
304        assert_eq!(date_suffix("RxNorm_full_prescribe_current"), None);
305        assert_eq!(date_suffix("Readme"), None);
306    }
307}