Skip to main content

argenus/
seqio.rs

1//! Sequence I/O Module
2//!
3//! Provides unified reading capabilities for biological sequence files.
4//! Supports both FASTA and FASTQ formats, including gzip-compressed files.
5//!
6//! # Supported Formats
7//! - FASTA: Standard sequence format with header and sequence lines
8//! - FASTQ: Sequence format with quality scores (plain or gzipped)
9//!
10//! # Examples
11//! ```no_run
12//! use argenus::seqio::{FastaReader, FastqFile};
13//!
14//! // Read FASTA file
15//! let mut reader = FastaReader::open("sequences.fas").unwrap();
16//! while let Some(record) = reader.read_next().unwrap() {
17//!     println!("{}: {} bp", record.name, record.seq.len());
18//! }
19//!
20//! // Read FASTQ file (auto-detects gzip)
21//! let mut reader = FastqFile::open("reads.fq.gz").unwrap();
22//! while let Some(record) = reader.read_next().unwrap() {
23//!     println!("{}: {} bp", record.name, record.seq.len());
24//! }
25//! ```
26
27use anyhow::{Context, Result};
28use flate2::read::MultiGzDecoder;
29use std::fs::File;
30use std::io::{BufRead, BufReader, Read};
31use std::path::Path;
32
33// ============================================================================
34// FASTA Format
35// ============================================================================
36
37/// A FASTA record containing sequence name and nucleotide sequence.
38///
39/// # Fields
40/// - `name`: Sequence identifier (text after '>' up to first whitespace)
41/// - `seq`: Nucleotide sequence (concatenated from all sequence lines)
42#[derive(Debug, Clone)]
43pub struct FastaRecord {
44    /// Sequence identifier extracted from the header line.
45    pub name: String,
46    /// Nucleotide sequence (may contain standard IUPAC codes).
47    pub seq: String,
48}
49
50/// Sequential reader for FASTA format files.
51///
52/// Reads records one at a time with minimal memory footprint.
53/// Handles multi-line sequences and strips whitespace automatically.
54pub struct FastaReader {
55    reader: BufReader<File>,
56    line_buf: String,
57    current_name: Option<String>,
58}
59
60impl FastaReader {
61    /// Opens a FASTA file for reading.
62    ///
63    /// # Arguments
64    /// * `path` - Path to the FASTA file
65    ///
66    /// # Returns
67    /// A new FastaReader instance, or an error if the file cannot be opened.
68    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
69        let file = File::open(path.as_ref())
70            .with_context(|| format!("Failed to open FASTA: {}", path.as_ref().display()))?;
71        let mut reader = Self {
72            reader: BufReader::with_capacity(1024 * 1024, file),
73            line_buf: String::with_capacity(256),
74            current_name: None,
75        };
76
77        // Read first header line to initialise state
78        reader.line_buf.clear();
79        if reader.reader.read_line(&mut reader.line_buf)? > 0
80            && reader.line_buf.starts_with('>') {
81                reader.current_name = Some(
82                    reader.line_buf[1..]
83                        .split_whitespace()
84                        .next()
85                        .unwrap_or("")
86                        .to_string(),
87                );
88            }
89
90        Ok(reader)
91    }
92
93    /// Reads the next FASTA record from the file.
94    ///
95    /// # Returns
96    /// - `Ok(Some(record))` - Successfully read a record
97    /// - `Ok(None)` - End of file reached
98    /// - `Err(e)` - I/O error occurred
99    pub fn read_next(&mut self) -> Result<Option<FastaRecord>> {
100        let name = match self.current_name.take() {
101            Some(n) => n,
102            None => return Ok(None),
103        };
104
105        let mut seq = String::with_capacity(10000);
106
107        loop {
108            self.line_buf.clear();
109            if self.reader.read_line(&mut self.line_buf)? == 0 {
110                // End of file reached
111                break;
112            }
113
114            if self.line_buf.starts_with('>') {
115                // New record header encountered
116                self.current_name = Some(
117                    self.line_buf[1..]
118                        .split_whitespace()
119                        .next()
120                        .unwrap_or("")
121                        .to_string(),
122                );
123                break;
124            } else {
125                // Sequence line - append to current sequence
126                seq.push_str(self.line_buf.trim_end());
127            }
128        }
129
130        Ok(Some(FastaRecord { name, seq }))
131    }
132}
133
134impl Iterator for FastaReader {
135    type Item = Result<FastaRecord>;
136
137    fn next(&mut self) -> Option<Self::Item> {
138        match self.read_next() {
139            Ok(Some(record)) => Some(Ok(record)),
140            Ok(None) => None,
141            Err(e) => Some(Err(e)),
142        }
143    }
144}
145
146// ============================================================================
147// FASTQ Format
148// ============================================================================
149
150/// A FASTQ record containing sequence name, nucleotide sequence, and quality scores.
151///
152/// # Fields
153/// - `name`: Read identifier (text after '@')
154/// - `seq`: Nucleotide sequence
155/// - `qual`: Quality string (Phred+33 encoded)
156#[derive(Debug, Clone)]
157pub struct FastqRecord {
158    /// Read identifier from the header line.
159    pub name: String,
160    /// Nucleotide sequence.
161    pub seq: String,
162    /// Quality scores (same length as seq, Phred+33 encoded).
163    pub qual: String,
164}
165
166/// Generic FASTQ reader supporting any Read source.
167///
168/// Use `FastqReader<File>` for plain files or
169/// `FastqReader<MultiGzDecoder<File>>` for gzipped files.
170pub struct FastqReader<R: Read> {
171    reader: BufReader<R>,
172    line_buf: String,
173}
174
175impl FastqReader<File> {
176    /// Opens a plain (uncompressed) FASTQ file.
177    ///
178    /// # Arguments
179    /// * `path` - Path to the FASTQ file
180    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
181        let file = File::open(path.as_ref())
182            .with_context(|| format!("Failed to open FASTQ: {}", path.as_ref().display()))?;
183        Ok(Self {
184            reader: BufReader::with_capacity(1024 * 1024, file),
185            line_buf: String::with_capacity(512),
186        })
187    }
188}
189
190impl FastqReader<MultiGzDecoder<File>> {
191    /// Opens a gzip-compressed FASTQ file.
192    ///
193    /// # Arguments
194    /// * `path` - Path to the .fastq.gz or .fq.gz file
195    pub fn open_gz<P: AsRef<Path>>(path: P) -> Result<Self> {
196        let file = File::open(path.as_ref())
197            .with_context(|| format!("Failed to open FASTQ.gz: {}", path.as_ref().display()))?;
198        let decoder = MultiGzDecoder::new(file);
199        Ok(Self {
200            reader: BufReader::with_capacity(1024 * 1024, decoder),
201            line_buf: String::with_capacity(512),
202        })
203    }
204}
205
206impl<R: Read> FastqReader<R> {
207    /// Reads the next FASTQ record (4 lines per record).
208    ///
209    /// # FASTQ Format
210    /// ```text
211    /// @read_name
212    /// SEQUENCE
213    /// +
214    /// QUALITY
215    /// ```
216    ///
217    /// # Returns
218    /// - `Ok(Some(record))` - Successfully read a record
219    /// - `Ok(None)` - End of file reached
220    /// - `Err(e)` - I/O or format error
221    pub fn read_next(&mut self) -> Result<Option<FastqRecord>> {
222        // Line 1: @name
223        self.line_buf.clear();
224        if self.reader.read_line(&mut self.line_buf)? == 0 {
225            return Ok(None);
226        }
227        let name = self.line_buf.trim_start_matches('@').trim_end().to_string();
228        if name.is_empty() {
229            return Ok(None);
230        }
231
232        // Line 2: sequence
233        self.line_buf.clear();
234        self.reader.read_line(&mut self.line_buf)?;
235        let seq = self.line_buf.trim_end().to_string();
236
237        // Line 3: + (separator, ignored)
238        self.line_buf.clear();
239        self.reader.read_line(&mut self.line_buf)?;
240
241        // Line 4: quality scores
242        self.line_buf.clear();
243        self.reader.read_line(&mut self.line_buf)?;
244        let qual = self.line_buf.trim_end().to_string();
245
246        Ok(Some(FastqRecord { name, seq, qual }))
247    }
248}
249
250/// Auto-detecting FASTQ file reader.
251///
252/// Automatically selects plain or gzip reader based on file extension.
253/// Files ending in `.gz` are treated as gzip-compressed.
254pub enum FastqFile {
255    /// Plain text FASTQ file.
256    Plain(FastqReader<File>),
257    /// Gzip-compressed FASTQ file.
258    Gzipped(FastqReader<MultiGzDecoder<File>>),
259}
260
261impl FastqFile {
262    /// Opens a FASTQ file with automatic compression detection.
263    ///
264    /// # Arguments
265    /// * `path` - Path to FASTQ file (plain or .gz)
266    ///
267    /// # Compression Detection
268    /// Files with `.gz` extension are opened with gzip decompression.
269    /// All other files are opened as plain text.
270    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
271        let path = path.as_ref();
272        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
273
274        if ext == "gz" {
275            Ok(FastqFile::Gzipped(FastqReader::open_gz(path)?))
276        } else {
277            Ok(FastqFile::Plain(FastqReader::open(path)?))
278        }
279    }
280
281    /// Reads the next FASTQ record.
282    ///
283    /// Delegates to the appropriate reader based on file type.
284    pub fn read_next(&mut self) -> Result<Option<FastqRecord>> {
285        match self {
286            FastqFile::Plain(r) => r.read_next(),
287            FastqFile::Gzipped(r) => r.read_next(),
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_fastq_record() {
298        let record = FastqRecord {
299            name: "read1".to_string(),
300            seq: "ATGC".to_string(),
301            qual: "IIII".to_string(),
302        };
303        assert_eq!(record.seq.len(), record.qual.len());
304    }
305}