1#![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#[derive(Debug, thiserror::Error)]
23pub enum RrfError {
24 #[error("cannot read {path}")]
26 Io {
27 path: PathBuf,
29 #[source]
31 source: std::io::Error,
32 },
33 #[error("the release under {root} has no {name}")]
35 Missing {
36 root: PathBuf,
38 name: &'static str,
40 },
41 #[error("cannot parse {path}")]
43 Csv {
44 path: PathBuf,
46 #[source]
48 source: csv::Error,
49 },
50 #[error("{path}:{line}: {columns} columns, the layout has {expected}")]
52 Columns {
53 path: PathBuf,
55 line: u64,
57 columns: usize,
59 expected: usize,
61 },
62 #[error("{path}:{line}: a field is not UTF-8")]
64 Utf8 {
65 path: PathBuf,
67 line: u64,
69 #[source]
71 source: std::str::Utf8Error,
72 },
73 #[error("{path}:{line}: `{value}` is not an identifier")]
75 Identifier {
76 path: PathBuf,
78 line: u64,
80 value: String,
82 #[source]
84 source: std::num::ParseIntError,
85 },
86}
87
88pub const CONSO: &str = "RXNCONSO.RRF";
90pub const REL: &str = "RXNREL.RRF";
92pub const SAT: &str = "RXNSAT.RRF";
94pub const STY: &str = "RXNSTY.RRF";
96
97#[derive(Debug, Clone)]
99pub struct Release {
100 root: PathBuf,
101 files: Vec<PathBuf>,
102}
103
104impl Release {
105 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 #[must_use]
125 pub fn root(&self) -> &Path {
126 &self.root
127 }
128
129 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 #[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 #[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 pub fn atoms(&self) -> Result<Rows<Atom>, RrfError> {
179 Rows::open(self.file(CONSO)?)
180 }
181
182 pub fn relationships(&self) -> Result<Rows<Relationship>, RrfError> {
188 Rows::open(self.file(REL)?)
189 }
190
191 pub fn attributes(&self) -> Result<Rows<Attribute>, RrfError> {
197 Rows::open(self.file(SAT)?)
198 }
199
200 pub fn semantic_types(&self) -> Result<Option<Rows<SemanticType>>, RrfError> {
206 self.optional(STY).map(Rows::open).transpose()
207 }
208}
209
210fn 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
232pub 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}