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}