fastx-io 0.3.0

Fast, streaming FASTA/FASTQ reader and writer for bioinformatics pipelines
Documentation
//! Records borrowed from the reader's buffer, for passes that never keep them.
//!
//! [`crate::FastxReader::read_into`] copies each record's bytes into a
//! [`Sequence`] you own. That copy is what lets you keep the record, pass it
//! between threads or hold several at once — and on a large file it is also
//! measurably a third of the parsing time.
//!
//! [`SequenceRef`] skips it. The reader guarantees a whole record is in its
//! buffer and hands out slices into that buffer, so the record is valid only
//! until the next one is read. For the common shape — FASTQ, or FASTA on a single
//! line — nothing is copied at all.
//!
//! ```
//! use fastx::FastxReader;
//!
//! let data = b"@r1 sample\nACGTACGT\n+\nIIIIIIII\n@r2\nTTTT\n+\n!!!!\n";
//! let mut reader = FastxReader::new(&data[..]);
//!
//! let mut bases = 0;
//! reader.for_each_ref(|record| {
//!     bases += record.len();
//!     Ok(())
//! })?;
//! assert_eq!(bases, 12);
//! # Ok::<(), fastx::Error>(())
//! ```

use std::borrow::Cow;

use crate::error::Result;
use crate::format::Format;
use crate::qual::{self, PHRED33};
use crate::record::Sequence;
use crate::seq::{self, BaseCounts};

/// One FASTA or FASTQ record, borrowed from the reader that produced it.
///
/// Valid only until the next record is read. Call [`SequenceRef::to_owned`] to
/// keep one.
///
/// Identifiers are `&[u8]` rather than `&str` deliberately: validating UTF-8 is
/// work this type exists to avoid, and sequence identifiers are ASCII in
/// practice. [`SequenceRef::id_str`] converts when you need text, borrowing
/// rather than allocating whenever the bytes are already valid UTF-8.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SequenceRef<'a> {
    id: &'a [u8],
    description: Option<&'a [u8]>,
    seq: &'a [u8],
    quality: Option<&'a [u8]>,
}

impl<'a> SequenceRef<'a> {
    /// Build a borrowed record from parts. The reader uses this; you rarely will.
    pub(crate) fn new(
        id: &'a [u8],
        description: Option<&'a [u8]>,
        seq: &'a [u8],
        quality: Option<&'a [u8]>,
    ) -> SequenceRef<'a> {
        SequenceRef {
            id,
            description,
            seq,
            quality,
        }
    }

    /// The identifier: the header up to the first ASCII whitespace.
    pub fn id(&self) -> &'a [u8] {
        self.id
    }

    /// The identifier as text, replacing anything that is not UTF-8.
    ///
    /// Borrows when the bytes are already valid UTF-8, which is the normal case.
    pub fn id_str(&self) -> Cow<'a, str> {
        String::from_utf8_lossy(self.id)
    }

    /// Everything after the first whitespace in the header, if any.
    pub fn description(&self) -> Option<&'a [u8]> {
        self.description
    }

    /// The description as text, replacing anything that is not UTF-8.
    pub fn description_str(&self) -> Option<Cow<'a, str>> {
        self.description.map(String::from_utf8_lossy)
    }

    /// The residues, without line breaks.
    pub fn seq(&self) -> &'a [u8] {
        self.seq
    }

    /// The Phred quality characters, FASTQ only, always Phred+33.
    pub fn quality(&self) -> Option<&'a [u8]> {
        self.quality
    }

    /// Number of residues.
    pub fn len(&self) -> usize {
        self.seq.len()
    }

    /// True when the record has no residues.
    pub fn is_empty(&self) -> bool {
        self.seq.is_empty()
    }

    /// True when the record carries quality scores.
    pub fn has_quality(&self) -> bool {
        self.quality.is_some()
    }

    /// The format this record can be written as losslessly.
    pub fn format(&self) -> Format {
        if self.has_quality() {
            Format::Fastq
        } else {
            Format::Fasta
        }
    }

    /// Per-base counts.
    pub fn base_counts(&self) -> BaseCounts {
        BaseCounts::of(self.seq)
    }

    /// GC fraction over unambiguous bases, `None` when there are none.
    pub fn gc_content(&self) -> Option<f64> {
        seq::gc_content(self.seq)
    }

    /// Error-probability-weighted mean quality (Phred+33).
    pub fn mean_quality(&self) -> Option<f64> {
        qual::mean_quality(self.quality?, PHRED33)
    }

    /// Expected number of sequencing errors in the read (Phred+33).
    pub fn expected_errors(&self) -> Option<f64> {
        Some(qual::expected_errors(self.quality?, PHRED33))
    }

    /// Iterator over overlapping k-mers.
    pub fn kmers(&self, k: usize) -> impl Iterator<Item = &'a [u8]> {
        seq::kmers(self.seq, k)
    }

    /// Copy into an owned [`Sequence`], for the records you want to keep.
    pub fn to_owned(&self) -> Sequence {
        Sequence {
            id: self.id_str().into_owned(),
            description: self.description_str().map(Cow::into_owned),
            seq: self.seq.to_vec(),
            quality: self.quality.map(<[u8]>::to_vec),
        }
    }

    /// Write this record out in `format`.
    ///
    /// FASTA lines are wrapped at `line_width`; `None` writes one line.
    pub fn write<W: std::io::Write>(
        &self,
        out: &mut W,
        format: Format,
        line_width: Option<usize>,
    ) -> Result<()> {
        match format {
            Format::Fasta => {
                out.write_all(b">")?;
                self.write_header(out)?;
                match line_width.filter(|w| *w > 0) {
                    None => {
                        out.write_all(self.seq)?;
                        out.write_all(b"\n")?;
                    }
                    Some(width) => {
                        if self.seq.is_empty() {
                            out.write_all(b"\n")?;
                        }
                        for chunk in self.seq.chunks(width) {
                            out.write_all(chunk)?;
                            out.write_all(b"\n")?;
                        }
                    }
                }
            }
            Format::Fastq => {
                let quality = self.quality.ok_or_else(|| crate::Error::MissingQuality {
                    id: self.id_str().into_owned(),
                })?;
                out.write_all(b"@")?;
                self.write_header(out)?;
                out.write_all(self.seq)?;
                out.write_all(b"\n+\n")?;
                out.write_all(quality)?;
                out.write_all(b"\n")?;
            }
        }
        Ok(())
    }

    fn write_header<W: std::io::Write>(&self, out: &mut W) -> Result<()> {
        out.write_all(self.id)?;
        if let Some(description) = self.description {
            out.write_all(b" ")?;
            out.write_all(description)?;
        }
        out.write_all(b"\n")?;
        Ok(())
    }
}

impl PartialEq<Sequence> for SequenceRef<'_> {
    /// Compare against an owned record, so tests can assert the two read paths
    /// agree without copying first.
    fn eq(&self, other: &Sequence) -> bool {
        self.id == other.id.as_bytes()
            && self.description == other.description.as_deref().map(str::as_bytes)
            && self.seq == other.seq.as_slice()
            && self.quality == other.quality.as_deref()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accessors_and_conversion() {
        let record = SequenceRef::new(b"r1", Some(b"a sample"), b"ACGTN", Some(b"IIII!"));
        assert_eq!(record.id(), b"r1");
        assert_eq!(record.id_str(), "r1");
        assert_eq!(record.description_str().unwrap(), "a sample");
        assert_eq!(record.len(), 5);
        assert!(record.has_quality());
        assert_eq!(record.format(), Format::Fastq);
        assert_eq!(record.gc_content(), Some(0.5));
        assert_eq!(record.kmers(4).count(), 2);

        let owned = record.to_owned();
        assert_eq!(owned.id, "r1");
        assert_eq!(owned.description.as_deref(), Some("a sample"));
        assert_eq!(owned.seq, b"ACGTN");
        assert_eq!(owned.quality.as_deref(), Some(&b"IIII!"[..]));
        assert!(record == owned);
    }

    #[test]
    fn non_utf8_ids_survive_as_bytes() {
        // The owned path is lossy here; the borrowed one keeps the bytes intact
        // and only converts on request.
        let record = SequenceRef::new(&[b'i', 0xff], None, b"AC", None);
        assert_eq!(record.id(), &[b'i', 0xff]);
        assert_eq!(record.id_str(), "i\u{fffd}");
    }

    #[test]
    fn writes_both_formats() {
        let record = SequenceRef::new(b"r", Some(b"d"), b"ACGTAC", Some(b"IIIIII"));
        let mut out = Vec::new();
        record.write(&mut out, Format::Fastq, None).unwrap();
        assert_eq!(out, b"@r d\nACGTAC\n+\nIIIIII\n");

        let mut out = Vec::new();
        record.write(&mut out, Format::Fasta, Some(4)).unwrap();
        assert_eq!(out, b">r d\nACGT\nAC\n");

        // FASTA without quality cannot be written as FASTQ.
        let fasta = SequenceRef::new(b"r", None, b"AC", None);
        let mut out = Vec::new();
        assert!(fasta.write(&mut out, Format::Fastq, None).is_err());
    }
}