Skip to main content

dino_seq/
fastq.rs

1use std::io::Read;
2
3use crate::error::{FastqError, Result};
4use crate::fastq_frame;
5
6mod chunk;
7mod frame;
8mod pair;
9mod record;
10
11pub use chunk::{FastqChunkConfig, FastqChunkSinkExt, FastqChunkStats, FastqRecordSink};
12use frame::{ChunkVisitContext, frame_records, visit_chunk_in_slab};
13use frame::{count_records_in_slab, visit_records_in_slab};
14pub use pair::{
15    FastqPair, InterleavedPairs, PairValidation, PairedFastqBatch, PairedFastqPairs,
16    PairedFastqReader, PairedRecords, PairingMode, paired_records,
17};
18use pair::{validate_even_pair_count, validate_interleaved_pair_ids, validate_paired_batches};
19pub use record::{FastqRecord, FastqVisitRecord, RecordRef, strip_pair_suffix};
20
21const DEFAULT_SLAB_SIZE: usize = 1024 * 1024;
22
23/// Configuration for FASTQ batch readers.
24///
25/// The default is a validated, unpaired reader with a 1 MiB slab and full pair
26/// identifier validation when pairing APIs are used.
27#[derive(Debug, Clone)]
28pub struct FastqConfig {
29    /// Target slab size in bytes.
30    ///
31    /// Values below 1024 are raised to 1024. Larger slabs reduce carry
32    /// frequency for long records but increase the reusable buffer size.
33    pub slab_size: usize,
34    /// Validate FASTQ structure while framing records.
35    ///
36    /// Validation checks the leading `@`, leading `+`, and sequence/quality
37    /// length equality. Disable only for trusted inputs.
38    pub validate: bool,
39    /// Whether a single stream should be interpreted as unpaired or interleaved.
40    pub pairing: PairingMode,
41    /// Identifier validation policy for paired APIs.
42    pub pair_validation: PairValidation,
43}
44
45impl Default for FastqConfig {
46    fn default() -> Self {
47        Self {
48            slab_size: DEFAULT_SLAB_SIZE,
49            validate: true,
50            pairing: PairingMode::None,
51            pair_validation: PairValidation::Full,
52        }
53    }
54}
55
56impl FastqConfig {
57    /// Treat a single FASTQ stream as adjacent interleaved read pairs.
58    pub fn interleaved(mut self) -> Self {
59        self.pairing = PairingMode::Interleaved;
60        self
61    }
62
63    /// Set the paired-read identifier validation policy.
64    pub fn pair_validation(mut self, pair_validation: PairValidation) -> Self {
65        self.pair_validation = pair_validation;
66        self
67    }
68}
69
70/// Aggregate statistics for sequence-only FASTQ workloads.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
72pub struct FastqStats {
73    /// Number of records observed.
74    pub records: u64,
75    /// Number of sequence bases observed.
76    pub bases: u64,
77    /// Number of quality bytes observed.
78    pub qualities: u64,
79    /// Number of header bytes observed, including leading `@`.
80    pub name_bytes: u64,
81    /// Lightweight deterministic checksum over record shape and first bases.
82    pub checksum: u64,
83}
84
85/// A batch of borrowed FASTQ records from one reader slab.
86///
87/// The batch is invalidated by the next mutable call on the reader that
88/// produced it. Process records before requesting another batch.
89#[derive(Debug)]
90pub struct FastqBatch<'a> {
91    bytes: &'a [u8],
92    records: &'a [RecordRef],
93    base_offset: u64,
94    first_record_index: u64,
95    pair_validation: PairValidation,
96}
97
98impl<'a> FastqBatch<'a> {
99    /// Number of records in the batch.
100    pub fn len(&self) -> usize {
101        self.records.len()
102    }
103
104    /// Whether the batch has no records.
105    pub fn is_empty(&self) -> bool {
106        self.records.is_empty()
107    }
108
109    /// Raw slab bytes backing this batch.
110    pub fn bytes(&self) -> &'a [u8] {
111        self.bytes
112    }
113
114    /// Record ranges within [`bytes`](Self::bytes).
115    pub fn record_refs(&self) -> &'a [RecordRef] {
116        self.records
117    }
118
119    /// Absolute byte offset of `bytes()[0]` in the original stream.
120    pub fn base_offset(&self) -> u64 {
121        self.base_offset
122    }
123
124    /// Zero-based index of the first record in this batch.
125    pub fn first_record_index(&self) -> u64 {
126        self.first_record_index
127    }
128
129    /// Pair validation mode inherited from the producing reader.
130    pub fn pair_validation(&self) -> PairValidation {
131        self.pair_validation
132    }
133
134    /// Iterate borrowed record views.
135    pub fn records(&self) -> impl Iterator<Item = FastqRecord<'a>> + 'a {
136        let bytes = self.bytes;
137        let records: &'a [RecordRef] = self.records;
138        records
139            .iter()
140            .map(move |record| FastqRecord { bytes, record })
141    }
142
143    /// Validate and iterate adjacent interleaved read pairs.
144    ///
145    /// Returns an error if the batch has an odd record count or mate
146    /// identifiers fail the configured [`PairValidation`] mode.
147    pub fn interleaved_pairs(&'a self) -> Result<InterleavedPairs<'a>> {
148        validate_even_pair_count(self)?;
149        validate_interleaved_pair_ids(self, self.pair_validation)?;
150        Ok(InterleavedPairs {
151            batch: self,
152            next: 0,
153        })
154    }
155
156    /// Validate and zip this batch with a mate batch from a separate reader.
157    ///
158    /// Identifier checks use this batch's configured [`PairValidation`] mode.
159    pub fn paired_with(&'a self, mate: &'a FastqBatch<'a>) -> Result<PairedRecords<'a>> {
160        validate_paired_batches(self, mate, self.pair_validation)?;
161        Ok(PairedRecords {
162            first: self,
163            second: mate,
164            next: 0,
165        })
166    }
167
168    fn record_at(&self, index: usize) -> FastqRecord<'a> {
169        let records: &'a [RecordRef] = self.records;
170        FastqRecord {
171            bytes: self.bytes,
172            record: &records[index],
173        }
174    }
175}
176
177/// Slab-based FASTQ reader over any [`Read`] input.
178///
179/// The reader frames four-line FASTQ records from a reusable byte slab. Records
180/// crossing slab boundaries are carried into the next read. Returned batches
181/// borrow from the reader and must be consumed before calling [`next_batch`]
182/// again.
183///
184/// [`next_batch`]: Self::next_batch
185#[derive(Debug)]
186pub struct FastqReader<R> {
187    reader: R,
188    config: FastqConfig,
189    buf: Vec<u8>,
190    len: usize,
191    next_start: usize,
192    chunk_resume_start: usize,
193    base_offset: u64,
194    record_index: u64,
195    eof: bool,
196    records: Vec<RecordRef>,
197}
198
199impl<R: Read> FastqReader<R> {
200    /// Create a reader with [`FastqConfig::default`].
201    pub fn new(reader: R) -> Self {
202        Self::with_config(reader, FastqConfig::default())
203    }
204
205    /// Create a reader with explicit configuration.
206    pub fn with_config(reader: R, config: FastqConfig) -> Self {
207        let slab_size = config.slab_size.max(1024);
208        let buf = vec![0; slab_size];
209        Self {
210            reader,
211            config: FastqConfig {
212                slab_size,
213                ..config
214            },
215            buf,
216            len: 0,
217            next_start: 0,
218            chunk_resume_start: 0,
219            base_offset: 0,
220            record_index: 0,
221            eof: false,
222            records: Vec::new(),
223        }
224    }
225
226    /// Return the wrapped reader.
227    pub fn into_inner(self) -> R {
228        self.reader
229    }
230
231    /// Read the next batch of FASTQ records.
232    ///
233    /// Returns `Ok(None)` at EOF. Format errors include byte offset, record
234    /// index, and line index when the failing location is known.
235    pub fn next_batch(&mut self) -> Result<Option<FastqBatch<'_>>> {
236        self.compact_carry();
237        self.fill_slab()?;
238
239        self.records.clear();
240        let first_record_index = self.record_index;
241        self.next_start = frame_records(
242            &self.buf[..self.len],
243            self.eof,
244            self.config.validate,
245            self.base_offset,
246            first_record_index,
247            &mut self.records,
248        )?;
249        self.align_interleaved_batch(first_record_index)?;
250
251        self.record_index += self.records.len() as u64;
252
253        if self.records.is_empty() {
254            if self.len == 0 && self.eof {
255                return Ok(None);
256            }
257            return Err(FastqError::RecordTooLarge {
258                slab_size: self.config.slab_size,
259            });
260        }
261
262        Ok(Some(FastqBatch {
263            bytes: &self.buf[..self.len],
264            records: &self.records,
265            base_offset: self.base_offset,
266            first_record_index,
267            pair_validation: self.config.pair_validation,
268        }))
269    }
270
271    /// Visit every record in the stream without building a batch side table.
272    ///
273    /// This is the fastest public parse-only path for single-pass consumers.
274    /// It still honors [`FastqConfig::validate`] for record structure, but it
275    /// yields individual records and does not perform paired-end identifier
276    /// validation. Use [`next_batch`](Self::next_batch),
277    /// [`interleaved_pairs`](FastqBatch::interleaved_pairs), or
278    /// [`PairedFastqReader`] when pair validation is required.
279    pub fn visit_records<F>(&mut self, mut visit: F) -> Result<()>
280    where
281        F: FnMut(FastqVisitRecord<'_>) -> Result<()>,
282    {
283        loop {
284            self.compact_carry();
285            self.fill_slab()?;
286
287            let (next_start, records) = visit_records_in_slab(
288                &self.buf[..self.len],
289                self.eof,
290                self.config.validate,
291                self.base_offset,
292                self.record_index,
293                &mut visit,
294            )?;
295            self.next_start = next_start;
296            self.record_index += records;
297
298            if records == 0 {
299                if self.len == 0 && self.eof {
300                    return Ok(());
301                }
302                return Err(FastqError::RecordTooLarge {
303                    slab_size: self.config.slab_size,
304                });
305            }
306        }
307    }
308
309    /// Count remaining records and bases without building record views.
310    ///
311    /// This consumes the reader to EOF. It validates the same four-line FASTQ
312    /// structure as [`visit_records`](Self::visit_records) when
313    /// [`FastqConfig::validate`] is enabled, but it avoids the per-record
314    /// visitor callback and borrowed-record construction.
315    #[inline]
316    pub fn count_records(&mut self) -> Result<FastqStats> {
317        let mut stats = FastqStats::default();
318        loop {
319            self.compact_carry();
320            self.fill_slab()?;
321
322            let (next_start, records) = count_records_in_slab(
323                &self.buf[..self.len],
324                self.eof,
325                self.config.validate,
326                self.base_offset,
327                self.record_index,
328                &mut stats,
329            )?;
330            self.next_start = next_start;
331            self.record_index += records;
332
333            if records == 0 {
334                if self.len == 0 && self.eof {
335                    return Ok(stats);
336                }
337                return Err(FastqError::RecordTooLarge {
338                    slab_size: self.config.slab_size,
339                });
340            }
341        }
342    }
343
344    /// Emit one resumable chunk of FASTQ records into a caller-owned sink.
345    ///
346    /// This is the low-overhead path for downstream tools that already have a
347    /// target output representation. It does not build the [`RecordRef`] side
348    /// table used by [`next_batch`](Self::next_batch), and it returns after the
349    /// configured chunk limit so callers can interleave parsing with their own
350    /// pipeline stages.
351    ///
352    /// Returns `Ok(None)` only when EOF is reached before emitting another
353    /// record. A subsequent call resumes at the first unconsumed record.
354    pub fn next_chunk_with_sink<S>(
355        &mut self,
356        config: FastqChunkConfig,
357        sink: &mut S,
358    ) -> Result<Option<FastqChunkStats>>
359    where
360        S: FastqRecordSink,
361    {
362        let config = config.normalized();
363        let first_record_index = self.record_index;
364        let mut chunk = FastqChunkStats::new(first_record_index);
365
366        loop {
367            let parse_start = if self.chunk_resume_start == 0 {
368                self.compact_carry();
369                self.fill_slab()?;
370                0
371            } else {
372                let parse_start = self.chunk_resume_start;
373                self.chunk_resume_start = 0;
374                parse_start
375            };
376
377            let (next_start, records, bases, stopped) = visit_chunk_in_slab(
378                &self.buf[parse_start..self.len],
379                ChunkVisitContext {
380                    eof: self.eof,
381                    validate: self.config.validate,
382                    base_offset: self.base_offset + parse_start as u64,
383                    first_record_index: self.record_index,
384                    config,
385                },
386                &chunk,
387                sink,
388            )?;
389            let next_start = parse_start + next_start;
390            if stopped && next_start < self.len {
391                self.chunk_resume_start = next_start;
392                self.next_start = 0;
393            } else {
394                self.next_start = next_start;
395            }
396            self.record_index += records;
397            chunk.records += records;
398            chunk.bases += bases;
399
400            if chunk.records > 0 && (stopped || (self.len == 0 && self.eof)) {
401                return Ok(Some(chunk));
402            }
403            if records == 0 {
404                if self.len == 0 && self.eof {
405                    return if chunk.records == 0 {
406                        Ok(None)
407                    } else {
408                        Ok(Some(chunk))
409                    };
410                }
411                return Err(FastqError::RecordTooLarge {
412                    slab_size: self.config.slab_size,
413                });
414            }
415        }
416    }
417
418    fn compact_carry(&mut self) {
419        if self.chunk_resume_start != 0 {
420            self.next_start = self.chunk_resume_start;
421            self.chunk_resume_start = 0;
422        }
423        if self.next_start == 0 {
424            return;
425        }
426        if self.next_start >= self.len {
427            self.base_offset += self.len as u64;
428            self.len = 0;
429            self.next_start = 0;
430            return;
431        }
432        let carry = self.len - self.next_start;
433        self.buf.copy_within(self.next_start..self.len, 0);
434        self.base_offset += self.next_start as u64;
435        self.len = carry;
436        self.next_start = 0;
437    }
438
439    fn fill_slab(&mut self) -> Result<()> {
440        while !self.eof && self.len < self.config.slab_size {
441            let n = self
442                .reader
443                .read(&mut self.buf[self.len..self.config.slab_size])?;
444            if n == 0 {
445                self.eof = true;
446                break;
447            }
448            self.len += n;
449        }
450        Ok(())
451    }
452
453    fn align_interleaved_batch(&mut self, first_record_index: u64) -> Result<()> {
454        if self.config.pairing != PairingMode::Interleaved || self.records.len().is_multiple_of(2) {
455            return Ok(());
456        }
457
458        let Some(last) = self.records.last() else {
459            return Ok(());
460        };
461        if self.eof {
462            return Err(fastq_frame::format_at(
463                "interleaved FASTQ ended with an unpaired record",
464                self.base_offset,
465                last.name.start as usize,
466                first_record_index + (self.records.len() - 1) as u64,
467                0,
468            ));
469        }
470
471        self.next_start = last.name.start as usize;
472        self.records.pop();
473        Ok(())
474    }
475
476    fn retain_records_from_parts(&mut self, next_start: usize, record_count: usize) {
477        self.chunk_resume_start = 0;
478        self.next_start = next_start;
479        self.record_index -= record_count as u64;
480    }
481}
482
483/// Visit records from an already resident FASTQ byte slice.
484///
485/// This path is intended for memory-mapped files, cached datasets, and other
486/// callers that already own a complete FASTQ byte buffer. It validates the same
487/// record structure as [`FastqReader::visit_records`] when
488/// [`FastqConfig::validate`] is enabled, but it does not copy the input into a
489/// streaming slab and does not perform paired-end identifier validation.
490///
491/// Returns the number of visited records.
492pub fn visit_fastq_bytes<F>(bytes: &[u8], config: FastqConfig, mut visit: F) -> Result<u64>
493where
494    F: FnMut(FastqVisitRecord<'_>) -> Result<()>,
495{
496    let (_, records) = visit_records_in_slab(bytes, true, config.validate, 0, 0, &mut visit)?;
497
498    Ok(records)
499}
500
501/// Count records and bases from a FASTQ reader without building record views.
502#[inline]
503pub fn count_fastq_read<R: Read>(reader: R) -> Result<FastqStats> {
504    count_fastq_read_with_config(reader, FastqConfig::default())
505}
506
507/// Count records and bases from a FASTQ reader with explicit parser settings.
508#[inline]
509pub fn count_fastq_read_with_config<R: Read>(reader: R, config: FastqConfig) -> Result<FastqStats> {
510    let mut reader = FastqReader::with_config(reader, config);
511    reader.count_records()
512}
513
514/// Count records and bases from an already resident FASTQ byte slice.
515#[inline]
516pub fn count_fastq_bytes(bytes: &[u8], config: FastqConfig) -> Result<FastqStats> {
517    let mut stats = FastqStats::default();
518    count_records_in_slab(bytes, true, config.validate, 0, 0, &mut stats)?;
519    Ok(stats)
520}
521
522#[cfg(test)]
523mod tests;