use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;
#[derive(Debug)]
pub struct BedRecord {
pub chrom: String,
pub start: usize,
pub end: usize,
pub name: Option<String>,
}
impl BedRecord {
fn from_str(record: &str) -> Result<Self, &'static str> {
let fields: Vec<&str> = record.split('\t').collect();
if fields.len() < 3 || fields.len() > 4 {
return Err("Incorrect number of fields | 3 or 4 fields allowed.");
}
Ok(Self {
chrom: fields[0].to_string(),
start: fields[1].parse().map_err(|_| "Invalid start value")?,
end: fields[2].parse().map_err(|_| "Invalid end value")?,
name: if fields.len() > 3 && fields[3] != "." {
Some(fields[3].to_string())
} else {
None
},
})
}
}
pub struct BedReader<R: BufRead> {
reader: R,
}
impl BedReader<BufReader<File>> {
pub fn from_path(path: &Path) -> io::Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
Ok(BedReader { reader })
}
}
impl<R: BufRead> BedReader<R> {
#[cfg_attr(not(test), allow(dead_code))]
pub fn from_reader(reader: R) -> Self {
BedReader { reader }
}
}
impl<R: BufRead> Iterator for BedReader<R> {
type Item = Result<BedRecord, &'static str>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let mut line = String::new();
match self.reader.read_line(&mut line) {
Ok(0) => return None,
Ok(_) => {
let trimmed = line.trim_end();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with("track")
|| trimmed.starts_with("browser")
{
continue;
}
return Some(BedRecord::from_str(trimmed));
}
Err(_) => return Some(Err("Error reading line")),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufReader;
fn parse(s: &str) -> Result<BedRecord, &'static str> {
BedRecord::from_str(s)
}
#[test]
fn three_column_no_name() {
let r = parse("chr1\t100\t200").unwrap();
assert_eq!(r.chrom, "chr1");
assert_eq!(r.start, 100);
assert_eq!(r.end, 200);
assert!(r.name.is_none());
}
#[test]
fn four_column_with_name() {
let r = parse("chr4\t39318077\t39318136\tRFC1").unwrap();
assert_eq!(r.chrom, "chr4");
assert_eq!(r.start, 39318077);
assert_eq!(r.end, 39318136);
assert_eq!(r.name.as_deref(), Some("RFC1"));
}
#[test]
fn dot_name_becomes_none() {
let r = parse("chr1\t0\t100\t.").unwrap();
assert!(r.name.is_none());
}
#[test]
fn too_few_fields_is_error() {
assert!(parse("chr1\t100").is_err());
}
#[test]
fn too_many_fields_is_error() {
assert!(parse("chr1\t100\t200\tname\textra").is_err());
}
#[test]
fn invalid_start_is_error() {
assert!(parse("chr1\tabc\t200").is_err());
}
#[test]
fn invalid_end_is_error() {
assert!(parse("chr1\t100\tabc").is_err());
}
fn reader_from(s: &str) -> BedReader<BufReader<&[u8]>> {
BedReader::from_reader(BufReader::new(s.as_bytes()))
}
#[test]
fn reader_yields_multiple_records() {
let data = "chr1\t100\t200\nchr2\t300\t400\tregion2\n";
let records: Vec<_> = reader_from(data).collect();
assert_eq!(records.len(), 2);
assert_eq!(records[0].as_ref().unwrap().chrom, "chr1");
assert_eq!(records[1].as_ref().unwrap().chrom, "chr2");
assert_eq!(
records[1].as_ref().unwrap().name.as_deref(),
Some("region2")
);
}
#[test]
fn reader_empty_input_yields_nothing() {
let records: Vec<_> = reader_from("").collect();
assert!(records.is_empty());
}
#[test]
fn reader_propagates_parse_error() {
let data = "chr1\tbad\t200\n";
let records: Vec<_> = reader_from(data).collect();
assert_eq!(records.len(), 1);
assert!(records[0].is_err());
}
#[test]
fn reader_skips_track_lines() {
let data = "track name=\"foo\" description=\"bar\"\nchr1\t100\t200\n";
let records: Vec<_> = reader_from(data).collect();
assert_eq!(records.len(), 1);
assert_eq!(records[0].as_ref().unwrap().chrom, "chr1");
}
#[test]
fn reader_skips_browser_lines() {
let data = "browser position chr1:100-200\nchr1\t100\t200\n";
let records: Vec<_> = reader_from(data).collect();
assert_eq!(records.len(), 1);
assert_eq!(records[0].as_ref().unwrap().chrom, "chr1");
}
#[test]
fn reader_skips_comment_and_blank_lines() {
let data = "# this is a comment\n\nchr1\t100\t200\n";
let records: Vec<_> = reader_from(data).collect();
assert_eq!(records.len(), 1);
assert_eq!(records[0].as_ref().unwrap().chrom, "chr1");
}
#[test]
fn reader_skips_ucsc_preamble() {
let data = concat!(
"browser position chr7:127471196-127495720\n",
"browser hide all\n",
"track name=\"demo\" visibility=2\n",
"chr7\t127471196\t127472363\tPos1\n",
"chr7\t127472363\t127473530\tPos2\n",
);
let records: Vec<_> = reader_from(data).collect();
assert_eq!(records.len(), 2);
assert_eq!(records[0].as_ref().unwrap().chrom, "chr7");
}
}