#![doc(test(attr(deny(warnings))))]
pub mod row;
use std::fs::File;
use std::path::{Path, PathBuf};
use crate::row::{Atom, Attribute, FromRecord, Relationship, SemanticType};
#[derive(Debug, thiserror::Error)]
pub enum RrfError {
#[error("cannot read {path}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the release under {root} has no {name}")]
Missing {
root: PathBuf,
name: &'static str,
},
#[error("cannot parse {path}")]
Csv {
path: PathBuf,
#[source]
source: csv::Error,
},
#[error("{path}:{line}: {columns} columns, the layout has {expected}")]
Columns {
path: PathBuf,
line: u64,
columns: usize,
expected: usize,
},
#[error("{path}:{line}: a field is not UTF-8")]
Utf8 {
path: PathBuf,
line: u64,
},
#[error("{path}:{line}: `{value}` is not an identifier")]
Identifier {
path: PathBuf,
line: u64,
value: String,
},
}
pub const CONSO: &str = "RXNCONSO.RRF";
pub const REL: &str = "RXNREL.RRF";
pub const SAT: &str = "RXNSAT.RRF";
pub const STY: &str = "RXNSTY.RRF";
#[derive(Debug, Clone)]
pub struct Release {
root: PathBuf,
files: Vec<PathBuf>,
}
impl Release {
pub fn open(root: &Path) -> Result<Self, RrfError> {
let mut files = Vec::new();
walk(root, &mut files)?;
files.sort();
let release = Self {
root: root.to_path_buf(),
files,
};
release.file(CONSO)?;
Ok(release)
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn file(&self, name: &'static str) -> Result<PathBuf, RrfError> {
self.optional(name).ok_or_else(|| RrfError::Missing {
root: self.root.clone(),
name,
})
}
#[must_use]
pub fn optional(&self, name: &str) -> Option<PathBuf> {
self.files
.iter()
.find(|p| {
p.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f.eq_ignore_ascii_case(name))
})
.cloned()
}
#[must_use]
pub fn version(&self) -> Option<String> {
let from_readme = self.files.iter().find_map(|p| {
let name = p.file_name()?.to_str()?;
let stem = name.strip_suffix(".txt")?;
(stem.starts_with("Readme"))
.then(|| date_suffix(stem))
.flatten()
});
from_readme.or_else(|| {
self.root
.file_name()
.and_then(|n| n.to_str())
.and_then(date_suffix)
})
}
pub fn atoms(&self) -> Result<Rows<Atom>, RrfError> {
Rows::open(self.file(CONSO)?)
}
pub fn relationships(&self) -> Result<Rows<Relationship>, RrfError> {
Rows::open(self.file(REL)?)
}
pub fn attributes(&self) -> Result<Rows<Attribute>, RrfError> {
Rows::open(self.file(SAT)?)
}
pub fn semantic_types(&self) -> Result<Option<Rows<SemanticType>>, RrfError> {
self.optional(STY).map(Rows::open).transpose()
}
}
fn date_suffix(name: &str) -> Option<String> {
let (_, tail) = name.rsplit_once('_')?;
(tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit())).then(|| tail.to_owned())
}
fn walk(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), RrfError> {
let io = |source| RrfError::Io {
path: root.to_path_buf(),
source,
};
for entry in std::fs::read_dir(root).map_err(io)? {
let path = entry.map_err(io)?.path();
if path.is_dir() {
walk(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
pub struct Rows<T> {
reader: csv::Reader<File>,
record: csv::ByteRecord,
path: PathBuf,
line: u64,
row: std::marker::PhantomData<T>,
}
impl<T> std::fmt::Debug for Rows<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Rows")
.field("path", &self.path)
.field("line", &self.line)
.finish_non_exhaustive()
}
}
impl<T: FromRecord> Rows<T> {
fn open(path: PathBuf) -> Result<Self, RrfError> {
let file = File::open(&path).map_err(|source| RrfError::Io {
path: path.clone(),
source,
})?;
let reader = csv::ReaderBuilder::new()
.delimiter(b'|')
.has_headers(false)
.flexible(true)
.quoting(false)
.from_reader(file);
Ok(Self {
reader,
record: csv::ByteRecord::new(),
path,
line: 0,
row: std::marker::PhantomData,
})
}
}
impl<T: FromRecord> Iterator for Rows<T> {
type Item = Result<T, RrfError>;
fn next(&mut self) -> Option<Self::Item> {
match self.reader.read_byte_record(&mut self.record) {
Ok(false) => None,
Ok(true) => {
self.line = self.line.saturating_add(1);
Some(T::from_record(&self.record, &self.path, self.line))
}
Err(source) => Some(Err(RrfError::Csv {
path: self.path.clone(),
source,
})),
}
}
}
#[cfg(test)]
mod tests {
use super::date_suffix;
#[test]
fn the_version_is_the_eight_digit_date() {
assert_eq!(
date_suffix("Readme_Full_Prescribe_09082026").as_deref(),
Some("09082026")
);
assert_eq!(
date_suffix("RxNorm_full_09082026").as_deref(),
Some("09082026")
);
assert_eq!(date_suffix("RxNorm_full_prescribe_current"), None);
assert_eq!(date_suffix("Readme"), None);
}
}