use std::fs::File;
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
use crate::id::IdError;
use crate::time::EffectiveTimeError;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FieldError {
#[error(transparent)]
Id(#[from] IdError),
#[error(transparent)]
Time(#[from] EffectiveTimeError),
#[error("{text:?} is not a boolean (0 or 1)")]
Boolean {
text: String,
},
#[error("{text:?} is not an integer")]
Integer {
text: String,
},
#[error("column {column} is missing")]
Missing {
column: usize,
},
#[error("{text:?} is not a valid {what}")]
Invalid {
what: &'static str,
text: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum Rf2Error {
#[error("cannot read {path}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("{path}: header is {actual:?}, expected {expected:?}")]
Header {
path: PathBuf,
expected: Vec<String>,
actual: Vec<String>,
},
#[error("{path}: the file is empty")]
Empty {
path: PathBuf,
},
#[error("{path}: malformed row")]
Csv {
path: PathBuf,
#[source]
source: csv::Error,
},
#[error("{path}:{line}: {actual} fields, expected {expected}")]
FieldCount {
path: PathBuf,
line: u64,
expected: usize,
actual: usize,
},
#[error("{path}:{line}: column {column} ({name}): {source}")]
Field {
path: PathBuf,
line: u64,
column: usize,
name: String,
#[source]
source: FieldError,
},
}
#[derive(Debug)]
pub struct Record<'a> {
path: &'a Path,
line: u64,
columns: &'a [String],
fields: csv::StringRecord,
}
impl Record<'_> {
#[must_use]
pub fn line(&self) -> u64 {
self.line
}
#[must_use]
pub fn column_name(&self, index: usize) -> Option<&str> {
self.columns.get(index).map(String::as_str)
}
pub fn text(&self, index: usize) -> Result<&str, Rf2Error> {
self.fields
.get(index)
.ok_or_else(|| self.field_error(index, FieldError::Missing { column: index }))
}
pub fn parse<T, E: Into<FieldError>>(
&self,
index: usize,
parse: impl FnOnce(&str) -> Result<T, E>,
) -> Result<T, Rf2Error> {
let text = self.text(index)?;
parse(text).map_err(|e| self.field_error(index, e.into()))
}
pub fn boolean(&self, index: usize) -> Result<bool, Rf2Error> {
self.parse(index, |text| match text {
"1" => Ok(true),
"0" => Ok(false),
other => Err(FieldError::Boolean {
text: other.to_owned(),
}),
})
}
pub fn integer(&self, index: usize) -> Result<i64, Rf2Error> {
self.parse(index, |text| {
let Ok(value) = text.parse::<i64>() else {
return Err(FieldError::Integer {
text: text.to_owned(),
});
};
Ok(value)
})
}
#[must_use]
pub fn field_error(&self, index: usize, source: FieldError) -> Rf2Error {
Rf2Error::Field {
path: self.path.to_path_buf(),
line: self.line,
column: index,
name: self.columns.get(index).cloned().unwrap_or_default(),
source,
}
}
}
#[derive(Debug)]
pub struct Rf2Reader<R: Read> {
path: PathBuf,
columns: Vec<String>,
inner: csv::Reader<R>,
line: u64,
}
impl Rf2Reader<BufReader<File>> {
pub fn open(path: &Path, expected: &[&str]) -> Result<Self, Rf2Error> {
let file = File::open(path).map_err(|source| Rf2Error::Io {
path: path.to_path_buf(),
source,
})?;
Self::new(path, BufReader::new(file), expected)
}
}
impl<R: Read> Rf2Reader<R> {
pub fn new(path: &Path, read: R, expected: &[&str]) -> Result<Self, Rf2Error> {
Self::new_with(path, read, |actual| {
if actual == expected {
Ok(())
} else {
Err(Rf2Error::Header {
path: path.to_path_buf(),
expected: expected.iter().map(|c| (*c).to_owned()).collect(),
actual: actual.to_vec(),
})
}
})
}
pub fn new_with(
path: &Path,
read: R,
check: impl FnOnce(&[String]) -> Result<(), Rf2Error>,
) -> Result<Self, Rf2Error> {
let mut inner = csv::ReaderBuilder::new()
.delimiter(b'\t')
.quoting(false)
.has_headers(true)
.flexible(true)
.from_reader(read);
let header = inner.headers().map_err(|source| Rf2Error::Csv {
path: path.to_path_buf(),
source,
})?;
let actual: Vec<String> = header
.iter()
.map(|field| field.trim_start_matches('\u{feff}').to_owned())
.collect();
if actual.is_empty() || actual == [""] {
return Err(Rf2Error::Empty {
path: path.to_path_buf(),
});
}
check(&actual)?;
Ok(Self {
path: path.to_path_buf(),
columns: actual,
inner,
line: 1,
})
}
pub fn new_validated(
path: &Path,
read: R,
actual: &[String],
expected: &[&str],
) -> Result<Self, Rf2Error> {
if actual != expected {
return Err(Rf2Error::Header {
path: path.to_path_buf(),
expected: expected.iter().map(|c| (*c).to_owned()).collect(),
actual: actual.to_vec(),
});
}
let inner = csv::ReaderBuilder::new()
.delimiter(b'\t')
.quoting(false)
.has_headers(false)
.flexible(true)
.from_reader(read);
Ok(Self {
path: path.to_path_buf(),
columns: actual.to_vec(),
inner,
line: 1,
})
}
#[must_use]
pub fn columns(&self) -> &[String] {
&self.columns
}
pub fn next_record(&mut self) -> Result<Option<Record<'_>>, Rf2Error> {
let mut fields = csv::StringRecord::new();
let read = self
.inner
.read_record(&mut fields)
.map_err(|source| Rf2Error::Csv {
path: self.path.clone(),
source,
})?;
if !read {
return Ok(None);
}
self.line = self.line.saturating_add(1);
if fields.len() != self.columns.len() {
return Err(Rf2Error::FieldCount {
path: self.path.clone(),
line: self.line,
expected: self.columns.len(),
actual: fields.len(),
});
}
Ok(Some(Record {
path: &self.path,
line: self.line,
columns: &self.columns,
fields,
}))
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Rf2Error, Rf2Reader};
#[test]
fn header_is_validated_and_rows_carry_line_numbers() {
let text = "id\teffectiveTime\tactive\r\n1\t20200131\t1\r\n2\t20200131\t0\r\n";
let mut reader = Rf2Reader::new(
Path::new("x.txt"),
text.as_bytes(),
&["id", "effectiveTime", "active"],
)
.expect("header matches");
let first = reader.next_record().expect("row").expect("present");
assert_eq!(first.line(), 2);
assert_eq!(first.text(0).expect("field"), "1");
assert!(first.boolean(2).expect("bool"));
let second = reader.next_record().expect("row").expect("present");
assert!(!second.boolean(2).expect("bool"));
assert!(reader.next_record().expect("end").is_none());
}
#[test]
fn a_wrong_header_or_field_count_is_refused() {
let wrong = Rf2Reader::new(
Path::new("x.txt"),
"id\tactive\r\n".as_bytes(),
&["id", "effectiveTime"],
);
assert!(matches!(wrong, Err(Rf2Error::Header { .. })));
let mut short = Rf2Reader::new(
Path::new("x.txt"),
"id\tactive\r\n1\r\n".as_bytes(),
&["id", "active"],
)
.expect("header matches");
assert!(matches!(
short.next_record(),
Err(Rf2Error::FieldCount { line: 2, .. })
));
assert!(matches!(
Rf2Reader::new(Path::new("x.txt"), "".as_bytes(), &["id"]),
Err(Rf2Error::Empty { .. })
));
}
#[test]
fn a_byte_order_mark_does_not_break_the_header() {
let text = "\u{feff}id\tactive\r\n";
let reader = Rf2Reader::new(Path::new("x.txt"), text.as_bytes(), &["id", "active"]);
assert!(reader.is_ok());
}
}