Skip to main content

gxfkit_core/
reader.rs

1//! A streaming GFF3 line reader.
2
3use crate::attributes::Attributes;
4use crate::model::{Record, Strand};
5use std::io::BufRead;
6
7#[derive(Debug)]
8pub enum ParseError {
9    Io(std::io::Error),
10    /// A data line that did not have 9 tab-separated columns.
11    BadColumnCount {
12        line_no: usize,
13        found: usize,
14    },
15    BadCoordinate {
16        line_no: usize,
17        field: &'static str,
18    },
19}
20
21impl std::fmt::Display for ParseError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            ParseError::Io(e) => write!(f, "I/O error: {e}"),
25            ParseError::BadColumnCount { line_no, found } => {
26                write!(f, "line {line_no}: expected 9 columns, found {found}")
27            }
28            ParseError::BadCoordinate { line_no, field } => {
29                write!(f, "line {line_no}: invalid {field} coordinate")
30            }
31        }
32    }
33}
34
35impl std::error::Error for ParseError {}
36
37impl From<std::io::Error> for ParseError {
38    fn from(e: std::io::Error) -> Self {
39        ParseError::Io(e)
40    }
41}
42
43/// Iterator-style reader. Comment lines (`#...`) and blank lines are skipped;
44/// the FASTA section that may follow `##FASTA` is ignored.
45pub struct GffReader<R: BufRead> {
46    inner: R,
47    line_no: usize,
48    buf: Vec<u8>,
49    in_fasta: bool,
50}
51
52impl<R: BufRead> GffReader<R> {
53    pub fn new(inner: R) -> Self {
54        Self {
55            inner,
56            line_no: 0,
57            buf: Vec::new(),
58            in_fasta: false,
59        }
60    }
61
62    /// Returns the next record, `None` at EOF, or a parse error.
63    ///
64    /// Lines are read as raw bytes and decoded with `from_utf8_lossy`, so a stray
65    /// non-UTF-8 byte (Latin-1 description text, etc.) is tolerated rather than
66    /// aborting the whole conversion.
67    pub fn next_record(&mut self) -> Result<Option<Record>, ParseError> {
68        loop {
69            self.buf.clear();
70            let n = self.inner.read_until(b'\n', &mut self.buf)?;
71            if n == 0 {
72                return Ok(None);
73            }
74            self.line_no += 1;
75            let raw = String::from_utf8_lossy(&self.buf);
76            let line = raw.trim_end_matches(['\n', '\r']);
77
78            if self.in_fasta {
79                continue;
80            }
81            if line.is_empty() {
82                continue;
83            }
84            if line.starts_with('#') {
85                // The FASTA directive may carry trailing whitespace.
86                if line.trim_end() == "##FASTA" {
87                    self.in_fasta = true;
88                }
89                continue;
90            }
91            // A bare ">" header without an explicit ##FASTA also begins FASTA.
92            if line.starts_with('>') {
93                self.in_fasta = true;
94                continue;
95            }
96
97            return Ok(Some(self.parse_line(line)?));
98        }
99    }
100
101    fn parse_line(&self, line: &str) -> Result<Record, ParseError> {
102        // The 9th (attribute) column may itself contain tabs, so cap the split at
103        // 9. Anything fewer than 9 columns is malformed.
104        let cols: Vec<&str> = line.splitn(9, '\t').collect();
105        if cols.len() < 9 {
106            return Err(ParseError::BadColumnCount {
107                line_no: self.line_no,
108                found: cols.len(),
109            });
110        }
111
112        let start = cols[3]
113            .parse::<u64>()
114            .map_err(|_| ParseError::BadCoordinate {
115                line_no: self.line_no,
116                field: "start",
117            })?;
118        let end = cols[4]
119            .parse::<u64>()
120            .map_err(|_| ParseError::BadCoordinate {
121                line_no: self.line_no,
122                field: "end",
123            })?;
124
125        Ok(Record {
126            seqid: cols[0].to_string(),
127            source: cols[1].to_string(),
128            feature_type: cols[2].to_string(),
129            start,
130            end,
131            score: cols[5].to_string(),
132            strand: Strand::parse(cols[6]),
133            phase: cols[7].to_string(),
134            attributes: Attributes::parse_gff3(cols[8]),
135        })
136    }
137}
138
139/// Read every record into a `Vec`. Convenient when the whole annotation is
140/// needed at once (gff2gtf resolves the full feature graph); streaming callers
141/// should use `next_record` directly.
142pub fn read_all<R: BufRead>(reader: R) -> Result<Vec<Record>, ParseError> {
143    let mut r = GffReader::new(reader);
144    let mut out = Vec::new();
145    while let Some(rec) = r.next_record()? {
146        out.push(rec);
147    }
148    Ok(out)
149}
150
151/// Read every parseable record, reporting and skipping malformed data records.
152///
153/// I/O errors are still fatal. This is intentionally opt-in so the default
154/// conversion path remains strict for AGAT parity.
155pub fn read_all_sanitize<R, F>(
156    reader: R,
157    mut on_skip: F,
158) -> Result<(Vec<Record>, usize), ParseError>
159where
160    R: BufRead,
161    F: FnMut(&ParseError),
162{
163    let mut r = GffReader::new(reader);
164    let mut out = Vec::new();
165    let mut skipped = 0;
166    loop {
167        match r.next_record() {
168            Ok(Some(rec)) => out.push(rec),
169            Ok(None) => return Ok((out, skipped)),
170            Err(ParseError::Io(e)) => return Err(ParseError::Io(e)),
171            Err(e) => {
172                skipped += 1;
173                on_skip(&e);
174            }
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use std::io::Cursor;
183
184    #[test]
185    fn reads_records_skips_comments_and_fasta() {
186        let data = "\
187##gff-version 3
188chr1\tEnsembl\tgene\t1\t100\t.\t+\t.\tID=g1
189chr1\tEnsembl\tmRNA\t1\t100\t.\t+\t.\tID=t1;Parent=g1
190
191##FASTA
192>chr1
193ACGT
194";
195        let recs = read_all(Cursor::new(data)).unwrap();
196        assert_eq!(recs.len(), 2);
197        assert_eq!(recs[0].id(), Some("g1"));
198        assert_eq!(recs[1].parent(), Some("g1"));
199    }
200
201    #[test]
202    fn tolerates_non_utf8_bytes() {
203        // 0xE9 is Latin-1 'é' — invalid UTF-8. Must not abort; lossy-decoded.
204        let mut data: Vec<u8> = b"chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=g1;Note=caf".to_vec();
205        data.push(0xE9);
206        data.push(b'\n');
207        let recs = read_all(Cursor::new(data)).unwrap();
208        assert_eq!(recs.len(), 1);
209        assert_eq!(recs[0].id(), Some("g1"));
210    }
211
212    #[test]
213    fn too_few_columns_reports_line_and_count() {
214        let data = b"chr1\tsrc\tgene\t1\t100\n".to_vec();
215        match read_all(Cursor::new(data)) {
216            Err(ParseError::BadColumnCount { line_no, found }) => {
217                assert_eq!(line_no, 1);
218                assert_eq!(found, 5);
219            }
220            other => panic!("expected BadColumnCount, got {other:?}"),
221        }
222    }
223
224    #[test]
225    fn sanitize_skips_bad_records_and_continues() {
226        let data = b"\
227chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=g1
228bad\ttoo\tfew
229chr1\tsrc\tmRNA\t1\t100\t.\t+\t.\tID=t1;Parent=g1
230"
231        .to_vec();
232        let mut diagnostics = Vec::new();
233        let (recs, skipped) =
234            read_all_sanitize(Cursor::new(data), |e| diagnostics.push(e.to_string())).unwrap();
235        assert_eq!(skipped, 1);
236        assert_eq!(recs.len(), 2);
237        assert!(diagnostics[0].contains("line 2: expected 9 columns"));
238    }
239}