Skip to main content

fastx/
record.rs

1//! The [`Sequence`] record type.
2
3use std::fmt;
4use std::io::Write;
5use std::ops::Range;
6
7use crate::error::{Error, Result};
8use crate::format::Format;
9use crate::qual::{self, PHRED33};
10use crate::seq::{self, Alphabet, BaseCounts};
11
12/// One FASTA or FASTQ record.
13///
14/// A record with `quality == None` is a FASTA record; a record with quality is a
15/// FASTQ record whose quality string always has the same length as `seq`.
16///
17/// Fields are public so that pipelines can build records without ceremony, but
18/// the constructors and [`Sequence::validate`] exist to keep the length
19/// invariant intact.
20///
21/// ```
22/// use fastx::Sequence;
23///
24/// let read = Sequence::fastq("read1", b"ACGT", b"IIII")?;
25/// assert_eq!(read.len(), 4);
26/// assert_eq!(read.mean_quality(), Some(40.0));
27/// # Ok::<(), fastx::Error>(())
28/// ```
29#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
30pub struct Sequence {
31    /// Identifier: the header up to the first whitespace, without `>`/`@`.
32    pub id: String,
33    /// Everything after the first whitespace in the header, if any.
34    pub description: Option<String>,
35    /// Residues as ASCII bytes, without line breaks.
36    pub seq: Vec<u8>,
37    /// Phred quality characters (not scores), FASTQ only.
38    pub quality: Option<Vec<u8>>,
39}
40
41impl Sequence {
42    /// A FASTA record.
43    pub fn fasta<I: Into<String>>(id: I, seq: impl Into<Vec<u8>>) -> Sequence {
44        Sequence {
45            id: id.into(),
46            description: None,
47            seq: seq.into(),
48            quality: None,
49        }
50    }
51
52    /// A FASTQ record. Fails if sequence and quality lengths differ.
53    pub fn fastq<I: Into<String>>(
54        id: I,
55        seq: impl Into<Vec<u8>>,
56        quality: impl Into<Vec<u8>>,
57    ) -> Result<Sequence> {
58        let id = id.into();
59        let seq = seq.into();
60        let quality = quality.into();
61        if seq.len() != quality.len() {
62            return Err(Error::LengthMismatch {
63                id,
64                seq: seq.len(),
65                quality: quality.len(),
66            });
67        }
68        Ok(Sequence {
69            id,
70            description: None,
71            seq,
72            quality: Some(quality),
73        })
74    }
75
76    /// Builder-style setter for the description.
77    pub fn with_description<D: Into<String>>(mut self, description: D) -> Sequence {
78        let description = description.into();
79        self.description = if description.is_empty() {
80            None
81        } else {
82            Some(description)
83        };
84        self
85    }
86
87    /// Number of residues.
88    pub fn len(&self) -> usize {
89        self.seq.len()
90    }
91
92    /// True when the record has no residues.
93    pub fn is_empty(&self) -> bool {
94        self.seq.is_empty()
95    }
96
97    /// True when the record carries quality scores.
98    pub fn has_quality(&self) -> bool {
99        self.quality.is_some()
100    }
101
102    /// The format this record can be written as losslessly.
103    pub fn format(&self) -> Format {
104        if self.has_quality() {
105            Format::Fastq
106        } else {
107            Format::Fasta
108        }
109    }
110
111    /// The header line without its leading `>`/`@`.
112    ///
113    /// ```
114    /// # use fastx::Sequence;
115    /// let s = Sequence::fasta("chr1", b"ACGT".to_vec()).with_description("human chromosome 1");
116    /// assert_eq!(s.header(), "chr1 human chromosome 1");
117    /// ```
118    pub fn header(&self) -> String {
119        match &self.description {
120            Some(d) => format!("{} {}", self.id, d),
121            None => self.id.clone(),
122        }
123    }
124
125    /// The residues as `&str`, if they are valid UTF-8 (ASCII in practice).
126    pub fn seq_str(&self) -> Result<&str> {
127        std::str::from_utf8(&self.seq).map_err(|e| Error::InvalidByte {
128            id: self.id.clone(),
129            pos: e.valid_up_to(),
130            byte: self.seq[e.valid_up_to()],
131        })
132    }
133
134    /// Reset the record to an empty state while keeping its allocations.
135    ///
136    /// This is what makes [`crate::FastxReader::read_into`] allocation-free.
137    pub fn clear(&mut self) {
138        self.id.clear();
139        self.description = None;
140        self.seq.clear();
141        if let Some(q) = self.quality.as_mut() {
142            q.clear();
143        }
144    }
145
146    /// Per-base counts.
147    pub fn base_counts(&self) -> BaseCounts {
148        BaseCounts::of(&self.seq)
149    }
150
151    /// GC fraction over unambiguous bases, `None` when there are none.
152    pub fn gc_content(&self) -> Option<f64> {
153        seq::gc_content(&self.seq)
154    }
155
156    /// Number of `N`/ambiguity bases.
157    pub fn ambiguous_count(&self) -> u64 {
158        self.base_counts().n
159    }
160
161    /// Reverse complement, reversing the quality string as well.
162    ///
163    /// ```
164    /// # use fastx::Sequence;
165    /// let read = Sequence::fastq("r", b"ACGT", b"ABCD")?.reverse_complement();
166    /// assert_eq!(read.seq, b"ACGT");
167    /// assert_eq!(read.quality.unwrap(), b"DCBA");
168    /// # Ok::<(), fastx::Error>(())
169    /// ```
170    pub fn reverse_complement(&self) -> Sequence {
171        Sequence {
172            id: self.id.clone(),
173            description: self.description.clone(),
174            seq: seq::reverse_complement(&self.seq),
175            quality: self
176                .quality
177                .as_ref()
178                .map(|q| q.iter().rev().copied().collect()),
179        }
180    }
181
182    /// Reverse complement in place.
183    pub fn reverse_complement_in_place(&mut self) {
184        seq::reverse_complement_in_place(&mut self.seq);
185        if let Some(q) = self.quality.as_mut() {
186            q.reverse();
187        }
188    }
189
190    /// Uppercase the residues in place (undoes soft masking).
191    pub fn make_uppercase(&mut self) {
192        self.seq.make_ascii_uppercase();
193    }
194
195    /// A sub-record covering `range`, carrying the matching quality slice.
196    ///
197    /// Returns [`Error::OutOfBounds`] if the range does not fit.
198    pub fn subseq(&self, range: Range<usize>) -> Result<Sequence> {
199        if range.start > range.end || range.end > self.seq.len() {
200            return Err(Error::OutOfBounds {
201                id: self.id.clone(),
202                start: range.start as u64,
203                end: range.end as u64,
204                length: self.seq.len() as u64,
205            });
206        }
207        Ok(Sequence {
208            id: self.id.clone(),
209            description: self.description.clone(),
210            seq: self.seq[range.clone()].to_vec(),
211            quality: self.quality.as_ref().map(|q| q[range].to_vec()),
212        })
213    }
214
215    /// Keep only `range`, discarding the rest, in place.
216    pub fn trim_to(&mut self, range: Range<usize>) -> Result<()> {
217        if range.start > range.end || range.end > self.seq.len() {
218            return Err(Error::OutOfBounds {
219                id: self.id.clone(),
220                start: range.start as u64,
221                end: range.end as u64,
222                length: self.seq.len() as u64,
223            });
224        }
225        self.seq.truncate(range.end);
226        self.seq.drain(..range.start);
227        if let Some(q) = self.quality.as_mut() {
228            q.truncate(range.end);
229            q.drain(..range.start);
230        }
231        Ok(())
232    }
233
234    /// Translate the residues into protein using the standard genetic code.
235    pub fn translate(&self, frame: usize, stop_at_stop: bool) -> Sequence {
236        Sequence {
237            id: self.id.clone(),
238            description: self.description.clone(),
239            seq: seq::translate(&self.seq, frame, stop_at_stop),
240            quality: None,
241        }
242    }
243
244    /// Iterator over overlapping k-mers.
245    pub fn kmers(&self, k: usize) -> impl Iterator<Item = &[u8]> {
246        seq::kmers(&self.seq, k)
247    }
248
249    /// Decoded Phred scores assuming the Phred+33 offset.
250    pub fn quality_scores(&self) -> Option<Vec<u8>> {
251        self.quality.as_ref().map(|q| qual::scores(q, PHRED33))
252    }
253
254    /// Decoded Phred scores for an explicit offset.
255    pub fn quality_scores_with(&self, offset: u8) -> Option<Vec<u8>> {
256        self.quality.as_ref().map(|q| qual::scores(q, offset))
257    }
258
259    /// Error-probability-weighted mean quality (Phred+33).
260    pub fn mean_quality(&self) -> Option<f64> {
261        qual::mean_quality(self.quality.as_deref()?, PHRED33)
262    }
263
264    /// Expected number of sequencing errors in the read (Phred+33).
265    pub fn expected_errors(&self) -> Option<f64> {
266        Some(qual::expected_errors(self.quality.as_deref()?, PHRED33))
267    }
268
269    /// Convert Phred+64 quality to Phred+33 in place. No-op for FASTA records.
270    pub fn convert_quality_offset(&mut self, from: u8, to: u8) {
271        if let Some(q) = self.quality.as_mut() {
272            for c in q.iter_mut() {
273                *c = qual::encode(qual::score(*c, from), to);
274            }
275        }
276    }
277
278    /// Drop the quality string, turning a FASTQ record into a FASTA record.
279    pub fn into_fasta(mut self) -> Sequence {
280        self.quality = None;
281        self
282    }
283
284    /// Check the invariants that the writers rely on.
285    ///
286    /// * the id is non-empty and free of whitespace/newlines
287    /// * the description contains no newlines
288    /// * residues are printable and non-whitespace, and in `alphabet`
289    /// * quality, when present, has the same length as the sequence and is
290    ///   printable ASCII
291    pub fn validate(&self, alphabet: Alphabet) -> Result<()> {
292        if self.id.is_empty() {
293            return Err(Error::Parse {
294                line: 0,
295                kind: crate::error::ParseError::EmptyId,
296            });
297        }
298        if let Some(pos) = self.id.bytes().position(|b| b.is_ascii_whitespace()) {
299            return Err(Error::InvalidByte {
300                id: self.id.clone(),
301                pos,
302                byte: self.id.as_bytes()[pos],
303            });
304        }
305        if let Some(d) = &self.description {
306            if let Some(pos) = d.bytes().position(|b| b == b'\n' || b == b'\r') {
307                return Err(Error::InvalidByte {
308                    id: self.id.clone(),
309                    pos,
310                    byte: d.as_bytes()[pos],
311                });
312            }
313        }
314        alphabet.validate_named(&self.seq, &self.id)?;
315        if let Some(q) = &self.quality {
316            if q.len() != self.seq.len() {
317                return Err(Error::LengthMismatch {
318                    id: self.id.clone(),
319                    seq: self.seq.len(),
320                    quality: q.len(),
321                });
322            }
323            if let Some(pos) = q.iter().position(|&b| !(33..=126).contains(&b)) {
324                return Err(Error::InvalidByte {
325                    id: self.id.clone(),
326                    pos,
327                    byte: q[pos],
328                });
329            }
330        }
331        Ok(())
332    }
333
334    /// Write this record as FASTA, wrapping sequence lines at `line_width`
335    /// (`None` writes the sequence on a single line).
336    ///
337    /// Fails with [`Error::InvalidByte`] if the sequence contains a byte FASTA
338    /// cannot represent: `\n`, `\r` or `>`. None of the three survives a
339    /// write/read cycle once line wrapping can move it, so writing one would
340    /// silently corrupt the record.
341    pub fn write_fasta<W: Write>(&self, out: &mut W, line_width: Option<usize>) -> Result<()> {
342        check_writable_residues(&self.seq, &self.id)?;
343        out.write_all(b">")?;
344        self.write_header(out)?;
345        match line_width.filter(|w| *w > 0) {
346            None => {
347                out.write_all(&self.seq)?;
348                out.write_all(b"\n")?;
349            }
350            Some(width) => {
351                for chunk in self.seq.chunks(width) {
352                    out.write_all(chunk)?;
353                    out.write_all(b"\n")?;
354                }
355                if self.seq.is_empty() {
356                    out.write_all(b"\n")?;
357                }
358            }
359        }
360        Ok(())
361    }
362
363    /// Write this record as FASTQ. Fails when the record has no quality, when
364    /// the lengths disagree, or when a byte cannot be represented.
365    pub fn write_fastq<W: Write>(&self, out: &mut W) -> Result<()> {
366        let quality = self.quality.as_ref().ok_or_else(|| Error::MissingQuality {
367            id: self.id.clone(),
368        })?;
369        if quality.len() != self.seq.len() {
370            return Err(Error::LengthMismatch {
371                id: self.id.clone(),
372                seq: self.seq.len(),
373                quality: quality.len(),
374            });
375        }
376        check_writable_residues(&self.seq, &self.id)?;
377        check_writable_fastq_sequence(&self.seq, &self.id)?;
378        check_writable_quality(quality, &self.id)?;
379        out.write_all(b"@")?;
380        self.write_header(out)?;
381        out.write_all(&self.seq)?;
382        out.write_all(b"\n+\n")?;
383        out.write_all(quality)?;
384        out.write_all(b"\n")?;
385        Ok(())
386    }
387
388    fn write_header<W: Write>(&self, out: &mut W) -> Result<()> {
389        out.write_all(self.id.as_bytes())?;
390        if let Some(d) = &self.description {
391            out.write_all(b" ")?;
392            out.write_all(d.as_bytes())?;
393        }
394        out.write_all(b"\n")?;
395        Ok(())
396    }
397
398    /// Render the record in its native format as a `String`.
399    pub fn to_string_in(&self, format: Format) -> Result<String> {
400        let mut buf = Vec::with_capacity(self.seq.len() * 2 + 64);
401        match format {
402            Format::Fasta => self.write_fasta(&mut buf, Some(60))?,
403            Format::Fastq => self.write_fastq(&mut buf)?,
404        }
405        Ok(String::from_utf8_lossy(&buf).into_owned())
406    }
407
408    /// Split a raw header (without the `>`/`@`) into id and description.
409    ///
410    /// Splitting happens on ASCII whitespace over the raw bytes. Doing it on
411    /// `char::is_whitespace` over a `str` would be wrong twice over: a header
412    /// containing, say, a non-breaking space would be split in a place other
413    /// tools do not split, and slicing after a multi-byte whitespace character
414    /// would land inside it and panic.
415    pub(crate) fn set_header(&mut self, header: &[u8]) {
416        let header = trim_ascii_end(header);
417        let id = header_id(header);
418        self.id.push_str(&String::from_utf8_lossy(id));
419        let description = trim_ascii_start(&header[id.len()..]);
420        self.description = if description.is_empty() {
421            None
422        } else {
423            Some(String::from_utf8_lossy(description).into_owned())
424        };
425    }
426}
427
428/// The identifier part of a raw header: everything up to the first ASCII
429/// whitespace byte.
430///
431/// The FASTA indexer uses this too. Both must agree on where an id ends, or an
432/// index will not contain the names the reader produces — which is precisely the
433/// bug that appears when one of them splits on Unicode whitespace and the other
434/// does not.
435pub(crate) fn header_id(header: &[u8]) -> &[u8] {
436    let end = header
437        .iter()
438        .position(|b| b.is_ascii_whitespace())
439        .unwrap_or(header.len());
440    &header[..end]
441}
442
443/// Reject sequence bytes that FASTA and FASTQ cannot represent.
444///
445/// Neither format has an escape mechanism, so some bytes simply cannot survive a
446/// write/read cycle and writing them would silently corrupt data:
447///
448/// * `\n` ends a line, so a sequence containing one would be read back as two.
449/// * `\r` is stripped when it precedes a line break, and line wrapping can move
450///   any `\r` to the end of a line — so `"AC\r"` would come back as `"AC"`.
451/// * `>` starts a header, and wrapping can move any `>` to the start of a line,
452///   splitting one record into two.
453///
454/// The check is a single SIMD pass, so it costs almost nothing next to the write
455/// itself. Refusing to write is the only honest option here: the alternative is
456/// output that this crate cannot read back.
457pub(crate) fn check_writable_residues(seq: &[u8], id: &str) -> Result<()> {
458    match memchr::memchr3(b'\n', b'\r', b'>', seq) {
459        None => Ok(()),
460        Some(pos) => Err(Error::InvalidByte {
461            id: id.to_string(),
462            pos,
463            byte: seq[pos],
464        }),
465    }
466}
467
468/// A FASTQ sequence may not begin with `+`, which would be read as the separator.
469pub(crate) fn check_writable_fastq_sequence(seq: &[u8], id: &str) -> Result<()> {
470    if seq.first() == Some(&b'+') {
471        return Err(Error::InvalidByte {
472            id: id.to_string(),
473            pos: 0,
474            byte: b'+',
475        });
476    }
477    Ok(())
478}
479
480/// Quality strings are length-delimited, so only line breaks are unrepresentable.
481pub(crate) fn check_writable_quality(quality: &[u8], id: &str) -> Result<()> {
482    match memchr::memchr2(b'\n', b'\r', quality) {
483        None => Ok(()),
484        Some(pos) => Err(Error::InvalidByte {
485            id: id.to_string(),
486            pos,
487            byte: quality[pos],
488        }),
489    }
490}
491
492/// `[u8]::trim_ascii_start` is stable only from Rust 1.80; our MSRV is 1.74.
493fn trim_ascii_start(mut bytes: &[u8]) -> &[u8] {
494    while let [first, rest @ ..] = bytes {
495        if first.is_ascii_whitespace() {
496            bytes = rest;
497        } else {
498            break;
499        }
500    }
501    bytes
502}
503
504fn trim_ascii_end(mut bytes: &[u8]) -> &[u8] {
505    while let [rest @ .., last] = bytes {
506        if last.is_ascii_whitespace() {
507            bytes = rest;
508        } else {
509            break;
510        }
511    }
512    bytes
513}
514
515impl fmt::Display for Sequence {
516    /// FASTA with 60-column wrapping, or FASTQ when quality is present.
517    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518        let mut buf = Vec::new();
519        let rendered = match self.format() {
520            Format::Fasta => self.write_fasta(&mut buf, Some(60)),
521            Format::Fastq => self.write_fastq(&mut buf),
522        };
523        rendered.map_err(|_| fmt::Error)?;
524        f.write_str(&String::from_utf8_lossy(&buf))
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn fastq_requires_matching_lengths() {
534        assert!(Sequence::fastq("r", b"ACGT", b"III").is_err());
535        assert!(Sequence::fastq("r", b"ACGT", b"IIII").is_ok());
536    }
537
538    #[test]
539    fn header_splitting() {
540        let mut s = Sequence::default();
541        s.set_header(b"chr1 human chromosome 1");
542        assert_eq!(s.id, "chr1");
543        assert_eq!(s.description.as_deref(), Some("human chromosome 1"));
544
545        let mut s = Sequence::default();
546        s.set_header(b"lonely");
547        assert_eq!(s.id, "lonely");
548        assert_eq!(s.description, None);
549
550        let mut s = Sequence::default();
551        s.set_header(b"tabbed\tdesc  with   spaces  ");
552        assert_eq!(s.id, "tabbed");
553        assert_eq!(s.description.as_deref(), Some("desc  with   spaces"));
554    }
555
556    #[test]
557    fn header_splitting_handles_non_ascii() {
558        // A non-breaking space (U+00A0) is not a separator, and slicing around
559        // it must not land inside the character.
560        let mut s = Sequence::default();
561        s.set_header("chr\u{a0}1 description".as_bytes());
562        assert_eq!(s.id, "chr\u{a0}1");
563        assert_eq!(s.description.as_deref(), Some("description"));
564
565        // Invalid UTF-8 is replaced rather than rejected.
566        let mut s = Sequence::default();
567        s.set_header(&[b'i', b'd', 0xff, b' ', b'd']);
568        assert!(s.id.starts_with("id"));
569        assert_eq!(s.description.as_deref(), Some("d"));
570
571        // A header that is nothing but whitespace leaves an empty id, which the
572        // reader turns into a parse error.
573        let mut s = Sequence::default();
574        s.set_header(b"   \t ");
575        assert!(s.id.is_empty());
576    }
577
578    #[test]
579    fn fasta_wrapping() {
580        let s = Sequence::fasta("x", b"AAAAACCCCC".to_vec());
581        let mut out = Vec::new();
582        s.write_fasta(&mut out, Some(5)).unwrap();
583        assert_eq!(out, b">x\nAAAAA\nCCCCC\n");
584
585        let mut out = Vec::new();
586        s.write_fasta(&mut out, None).unwrap();
587        assert_eq!(out, b">x\nAAAAACCCCC\n");
588
589        // An empty sequence still gets a (blank) sequence line.
590        let mut out = Vec::new();
591        Sequence::fasta("empty", Vec::new())
592            .write_fasta(&mut out, Some(60))
593            .unwrap();
594        assert_eq!(out, b">empty\n\n");
595    }
596
597    #[test]
598    fn fastq_output_and_missing_quality() {
599        let r = Sequence::fastq("r", b"ACGT", b"IIII")
600            .unwrap()
601            .with_description("d");
602        let mut out = Vec::new();
603        r.write_fastq(&mut out).unwrap();
604        assert_eq!(out, b"@r d\nACGT\n+\nIIII\n");
605
606        let mut out = Vec::new();
607        assert!(matches!(
608            Sequence::fasta("r", b"ACGT".to_vec()).write_fastq(&mut out),
609            Err(Error::MissingQuality { .. })
610        ));
611    }
612
613    #[test]
614    fn trimming_keeps_quality_aligned() {
615        let mut r = Sequence::fastq("r", b"AACCGGTT", b"01234567").unwrap();
616        r.trim_to(2..6).unwrap();
617        assert_eq!(r.seq, b"CCGG");
618        assert_eq!(r.quality.as_deref(), Some(&b"2345"[..]));
619        assert!(r.trim_to(0..99).is_err());
620    }
621
622    #[test]
623    fn subseq_bounds() {
624        let r = Sequence::fastq("r", b"AACCGG", b"012345").unwrap();
625        let sub = r.subseq(1..3).unwrap();
626        assert_eq!(sub.seq, b"AC");
627        assert_eq!(sub.quality.as_deref(), Some(&b"12"[..]));
628        #[allow(clippy::reversed_empty_ranges)] // deliberately backwards
629        {
630            assert!(r.subseq(4..2).is_err());
631        }
632        assert!(r.subseq(0..7).is_err());
633    }
634
635    #[test]
636    fn validation_catches_bad_records() {
637        let mut r = Sequence::fasta("ok", b"ACGT".to_vec());
638        assert!(r.validate(Alphabet::Dna).is_ok());
639        r.id = "has space".into();
640        assert!(r.validate(Alphabet::Dna).is_err());
641        r.id = String::new();
642        assert!(r.validate(Alphabet::Dna).is_err());
643
644        let mut r = Sequence::fastq("r", b"ACGT", b"IIII").unwrap();
645        r.quality = Some(b"II".to_vec());
646        assert!(matches!(
647            r.validate(Alphabet::Dna),
648            Err(Error::LengthMismatch { .. })
649        ));
650        r.quality = Some(b"II\nI".to_vec());
651        assert!(matches!(
652            r.validate(Alphabet::Dna),
653            Err(Error::InvalidByte { .. })
654        ));
655    }
656
657    #[test]
658    fn refuses_to_write_unrepresentable_residues() {
659        // Regression: the reader accepts a bare '\r' inside a sequence line, but
660        // writing it would put the '\r' before a newline, where reading strips
661        // it — so ">x\nAC\r\n" would come back as "AC". Refuse instead.
662        let mut out = Vec::new();
663        for bad in [&b"AC\r"[..], b"AC\nGT", b"AC>GT", b">AC"] {
664            let record = Sequence::fasta("x", bad.to_vec());
665            assert!(
666                matches!(
667                    record.write_fasta(&mut out, Some(60)),
668                    Err(Error::InvalidByte { .. })
669                ),
670                "{:?} should be rejected",
671                String::from_utf8_lossy(bad)
672            );
673        }
674        // Ordinary residues, gaps and stops are all still fine.
675        for good in [&b"ACGTN"[..], b"acgt-n.", b"MEEPQSDPSV*"] {
676            let record = Sequence::fasta("x", good.to_vec());
677            assert!(record.write_fasta(&mut out, Some(60)).is_ok());
678        }
679    }
680
681    #[test]
682    fn refuses_to_write_unrepresentable_fastq() {
683        let mut out = Vec::new();
684
685        // A leading '+' in the sequence would be read back as the separator.
686        let record = Sequence::fastq("x", b"+CGT", b"IIII").unwrap();
687        assert!(matches!(
688            record.write_fastq(&mut out),
689            Err(Error::InvalidByte {
690                pos: 0,
691                byte: b'+',
692                ..
693            })
694        ));
695        // '+' anywhere else is harmless.
696        assert!(Sequence::fastq("x", b"A+GT", b"IIII")
697            .unwrap()
698            .write_fastq(&mut out)
699            .is_ok());
700
701        // A line break in the quality string breaks the record.
702        let record = Sequence::fastq("x", b"ACGT", b"II\nI").unwrap();
703        assert!(matches!(
704            record.write_fastq(&mut out),
705            Err(Error::InvalidByte { byte: b'\n', .. })
706        ));
707        // '@' in quality is legal (Q31) and must not be rejected.
708        assert!(Sequence::fastq("x", b"ACGT", b"@@@@")
709            .unwrap()
710            .write_fastq(&mut out)
711            .is_ok());
712    }
713
714    #[test]
715    fn quality_offset_conversion() {
716        let mut r = Sequence::fastq("r", b"ACGT", b"hhhh").unwrap();
717        r.convert_quality_offset(crate::qual::PHRED64, PHRED33);
718        assert_eq!(r.quality.as_deref(), Some(&b"IIII"[..]));
719    }
720
721    #[test]
722    fn clear_keeps_capacity() {
723        let mut r = Sequence::fastq("r", b"ACGT", b"IIII").unwrap();
724        let cap = r.seq.capacity();
725        r.clear();
726        assert!(r.id.is_empty() && r.seq.is_empty());
727        assert_eq!(r.quality.as_deref(), Some(&b""[..]));
728        assert_eq!(r.seq.capacity(), cap);
729    }
730}