Skip to main content

fastx/
writer.rs

1//! FASTA/FASTQ writer with optional gzip compression and validation.
2
3use std::fmt;
4use std::fs::File;
5use std::io::{self, BufWriter, Write};
6use std::path::Path;
7
8use crate::error::{Error, Result};
9use crate::format::{Compression, CompressionLevel, Format};
10use crate::record::Sequence;
11use crate::seq::Alphabet;
12
13/// Default FASTA line width. NCBI uses 70, UCSC and samtools use 60.
14pub const DEFAULT_LINE_WIDTH: usize = 60;
15
16/// A writer for FASTA and FASTQ records.
17///
18/// FASTA sequence lines are wrapped at [`DEFAULT_LINE_WIDTH`] unless configured
19/// otherwise. Writing a FASTQ record without quality scores is an error rather
20/// than a silent fabrication of quality values; writing a FASTQ record to a FASTA
21/// writer simply drops the quality, which is the usual `fq2fa` conversion.
22///
23/// ```
24/// use fastx::{FastxWriter, Format, Sequence};
25///
26/// let mut out = Vec::new();
27/// {
28///     let mut writer = FastxWriter::new(&mut out, Format::Fasta).line_width(4);
29///     writer.write_record(&Sequence::fasta("a", b"ACGTAC"))?;
30///     writer.flush()?;
31/// }
32/// assert_eq!(out, b">a\nACGT\nAC\n");
33/// # Ok::<(), fastx::Error>(())
34/// ```
35pub struct FastxWriter<W: Write> {
36    inner: W,
37    format: Format,
38    line_width: Option<usize>,
39    validate: Option<Alphabet>,
40    written: u64,
41}
42
43impl<W: Write> FastxWriter<W> {
44    /// A writer for `format`, wrapping FASTA at [`DEFAULT_LINE_WIDTH`].
45    ///
46    /// Wrap `inner` in a [`BufWriter`] yourself for best throughput, or use
47    /// [`create`] which does it for you.
48    pub fn new(inner: W, format: Format) -> FastxWriter<W> {
49        FastxWriter {
50            inner,
51            format,
52            line_width: Some(DEFAULT_LINE_WIDTH),
53            validate: None,
54            written: 0,
55        }
56    }
57
58    /// Set the FASTA line width. `0` disables wrapping.
59    pub fn line_width(mut self, width: usize) -> Self {
60        self.line_width = if width == 0 { None } else { Some(width) };
61        self
62    }
63
64    /// Validate every record against `alphabet` before writing it.
65    pub fn validate(mut self, alphabet: Alphabet) -> Self {
66        self.validate = Some(alphabet);
67        self
68    }
69
70    /// The output format.
71    pub fn format(&self) -> Format {
72        self.format
73    }
74
75    /// Number of records written so far.
76    pub fn records_written(&self) -> u64 {
77        self.written
78    }
79
80    /// Write one record in the writer's format.
81    pub fn write_record(&mut self, record: &Sequence) -> Result<()> {
82        if let Some(alphabet) = self.validate {
83            record.validate(alphabet)?;
84        }
85        match self.format {
86            Format::Fasta => record.write_fasta(&mut self.inner, self.line_width)?,
87            Format::Fastq => record.write_fastq(&mut self.inner)?,
88        }
89        self.written += 1;
90        Ok(())
91    }
92
93    /// Write every record of an iterator.
94    pub fn write_all<'a, I>(&mut self, records: I) -> Result<()>
95    where
96        I: IntoIterator<Item = &'a Sequence>,
97    {
98        for record in records {
99            self.write_record(record)?;
100        }
101        Ok(())
102    }
103
104    /// Write a FASTA record from parts, without building a [`Sequence`].
105    pub fn write_fasta(&mut self, id: &str, description: Option<&str>, seq: &[u8]) -> Result<()> {
106        crate::record::check_writable_residues(seq, id)?;
107        self.write_header(b'>', id, description)?;
108        match self.line_width {
109            None => {
110                self.inner.write_all(seq)?;
111                self.inner.write_all(b"\n")?;
112            }
113            Some(width) => {
114                if seq.is_empty() {
115                    self.inner.write_all(b"\n")?;
116                }
117                for chunk in seq.chunks(width) {
118                    self.inner.write_all(chunk)?;
119                    self.inner.write_all(b"\n")?;
120                }
121            }
122        }
123        self.written += 1;
124        Ok(())
125    }
126
127    /// Write a FASTQ record from parts, without building a [`Sequence`].
128    pub fn write_fastq(
129        &mut self,
130        id: &str,
131        description: Option<&str>,
132        seq: &[u8],
133        quality: &[u8],
134    ) -> Result<()> {
135        if seq.len() != quality.len() {
136            return Err(Error::LengthMismatch {
137                id: id.to_string(),
138                seq: seq.len(),
139                quality: quality.len(),
140            });
141        }
142        crate::record::check_writable_residues(seq, id)?;
143        crate::record::check_writable_fastq_sequence(seq, id)?;
144        crate::record::check_writable_quality(quality, id)?;
145        self.write_header(b'@', id, description)?;
146        self.inner.write_all(seq)?;
147        self.inner.write_all(b"\n+\n")?;
148        self.inner.write_all(quality)?;
149        self.inner.write_all(b"\n")?;
150        self.written += 1;
151        Ok(())
152    }
153
154    fn write_header(&mut self, prefix: u8, id: &str, description: Option<&str>) -> Result<()> {
155        self.inner.write_all(&[prefix])?;
156        self.inner.write_all(id.as_bytes())?;
157        if let Some(description) = description.filter(|d| !d.is_empty()) {
158            self.inner.write_all(b" ")?;
159            self.inner.write_all(description.as_bytes())?;
160        }
161        self.inner.write_all(b"\n")?;
162        Ok(())
163    }
164
165    /// Flush buffered bytes to the underlying writer.
166    pub fn flush(&mut self) -> Result<()> {
167        self.inner.flush()?;
168        Ok(())
169    }
170
171    /// Flush and return the underlying writer.
172    ///
173    /// Always prefer this over dropping the writer: for gzip output it is the
174    /// only way to learn that finishing the stream failed.
175    pub fn finish(mut self) -> Result<W> {
176        self.inner.flush()?;
177        Ok(self.inner)
178    }
179
180    /// Borrow the underlying writer.
181    pub fn get_ref(&self) -> &W {
182        &self.inner
183    }
184}
185
186impl<W: Write> fmt::Debug for FastxWriter<W> {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.debug_struct("FastxWriter")
189            .field("format", &self.format)
190            .field("line_width", &self.line_width)
191            .field("validate", &self.validate)
192            .field("records_written", &self.written)
193            .finish_non_exhaustive()
194    }
195}
196
197/// Configuration for a [`FastxWriter`].
198///
199/// ```no_run
200/// use fastx::{Alphabet, WriterBuilder};
201///
202/// // Format and gzip are inferred from the path: this writes gzipped FASTQ.
203/// let mut writer = WriterBuilder::new()
204///     .line_width(80)
205///     .validate(Alphabet::Dna)
206///     .create("out.fq.gz")?;
207/// writer.finish()?;
208/// # Ok::<(), fastx::Error>(())
209/// ```
210#[derive(Debug, Clone)]
211pub struct WriterBuilder {
212    format: Option<Format>,
213    line_width: Option<usize>,
214    compression: Option<Compression>,
215    level: CompressionLevel,
216    validate: Option<Alphabet>,
217    buffer_size: usize,
218    blocks_per_batch: Option<usize>,
219}
220
221impl Default for WriterBuilder {
222    fn default() -> Self {
223        WriterBuilder {
224            format: None,
225            line_width: Some(DEFAULT_LINE_WIDTH),
226            compression: None,
227            level: CompressionLevel::default(),
228            validate: None,
229            buffer_size: 128 * 1024,
230            blocks_per_batch: None,
231        }
232    }
233}
234
235impl WriterBuilder {
236    /// A builder with default settings.
237    pub fn new() -> WriterBuilder {
238        WriterBuilder::default()
239    }
240
241    /// Force the output format instead of inferring it from the path.
242    pub fn format(mut self, format: Format) -> Self {
243        self.format = Some(format);
244        self
245    }
246
247    /// FASTA line width; `0` disables wrapping.
248    pub fn line_width(mut self, width: usize) -> Self {
249        self.line_width = if width == 0 { None } else { Some(width) };
250        self
251    }
252
253    /// Force compression instead of inferring it from the path.
254    pub fn compression(mut self, compression: Compression) -> Self {
255        self.compression = Some(compression);
256        self
257    }
258
259    /// gzip compression level.
260    pub fn level(mut self, level: CompressionLevel) -> Self {
261        self.level = level;
262        self
263    }
264
265    /// Validate records against an alphabet before writing.
266    pub fn validate(mut self, alphabet: Alphabet) -> Self {
267        self.validate = Some(alphabet);
268        self
269    }
270
271    /// Output buffer size in bytes.
272    pub fn buffer_size(mut self, bytes: usize) -> Self {
273        self.buffer_size = bytes;
274        self
275    }
276
277    /// How many BGZF blocks to compress at a time.
278    ///
279    /// Only affects BGZF output. Blocks are independent, so a batch is
280    /// compressed across cores and the bytes written are identical either way —
281    /// this trades `blocks × 64 KiB` of memory for parallelism. The default is
282    /// several blocks per core; pass 1 to force single-threaded compression.
283    pub fn blocks_per_batch(mut self, blocks: usize) -> Self {
284        self.blocks_per_batch = Some(blocks.max(1));
285        self
286    }
287
288    /// Build a writer around any [`Write`]. Requires a format to be set.
289    pub fn build<W: Write>(&self, inner: W) -> Result<FastxWriter<W>> {
290        let format = self.format.ok_or(Error::UnknownFormat {
291            hint: "no format given and no path to infer it from".to_string(),
292        })?;
293        Ok(FastxWriter {
294            inner,
295            format,
296            line_width: self.line_width,
297            validate: self.validate,
298            written: 0,
299        })
300    }
301
302    /// Create a file, inferring format and gzip compression from its extension.
303    pub fn create<P: AsRef<Path>>(&self, path: P) -> Result<BoxedWriter> {
304        let path = path.as_ref();
305        let format = match self.format.or_else(|| Format::from_path(path)) {
306            Some(format) => format,
307            None => {
308                return Err(Error::UnknownFormat {
309                    hint: format!("unrecognised extension in {}", path.display()),
310                })
311            }
312        };
313        // Compressed output defaults to BGZF rather than plain gzip. It is valid
314        // gzip either way, so nothing downstream breaks, but BGZF can later be
315        // indexed and randomly accessed, which plain gzip never can. Ask for
316        // `Compression::Gzip` explicitly to get a single deflate stream.
317        let compression = self
318            .compression
319            .unwrap_or(match Compression::from_path(path) {
320                Compression::Gzip => Compression::Bgzf,
321                other => other,
322            });
323        let file = File::create(path)
324            .map_err(|e| Error::Io(io::Error::new(e.kind(), format!("{}: {e}", path.display()))))?;
325        let buffered = BufWriter::with_capacity(self.buffer_size, file);
326        let sink: Box<dyn Write + Send> = match compression {
327            Compression::None => Box::new(buffered),
328            Compression::Gzip => gzip_writer(buffered, self.level)?,
329            Compression::Bgzf => bgzf_writer(buffered, self.level, self.blocks_per_batch)?,
330            Compression::Zstd => zstd_writer(buffered, self.level)?,
331        };
332        Ok(FastxWriter {
333            inner: sink,
334            format,
335            line_width: self.line_width,
336            validate: self.validate,
337            written: 0,
338        })
339    }
340
341    /// Write to standard output.
342    pub fn stdout(&self) -> Result<BoxedWriter> {
343        let format = self.format.unwrap_or(Format::Fasta);
344        let sink: Box<dyn Write + Send> = match self.compression.unwrap_or(Compression::None) {
345            Compression::None => Box::new(BufWriter::with_capacity(self.buffer_size, io::stdout())),
346            Compression::Gzip => gzip_writer(
347                BufWriter::with_capacity(self.buffer_size, io::stdout()),
348                self.level,
349            )?,
350            Compression::Bgzf => bgzf_writer(
351                BufWriter::with_capacity(self.buffer_size, io::stdout()),
352                self.level,
353                self.blocks_per_batch,
354            )?,
355            Compression::Zstd => zstd_writer(
356                BufWriter::with_capacity(self.buffer_size, io::stdout()),
357                self.level,
358            )?,
359        };
360        Ok(FastxWriter {
361            inner: sink,
362            format,
363            line_width: self.line_width,
364            validate: self.validate,
365            written: 0,
366        })
367    }
368}
369
370#[cfg(feature = "gzip")]
371fn gzip_writer<W: Write + Send + 'static>(
372    sink: W,
373    level: CompressionLevel,
374) -> Result<Box<dyn Write + Send>> {
375    Ok(Box::new(flate2::write::GzEncoder::new(
376        sink,
377        flate2::Compression::new(level.0.min(9)),
378    )))
379}
380
381#[cfg(not(feature = "gzip"))]
382fn gzip_writer<W: Write + Send + 'static>(
383    _sink: W,
384    _level: CompressionLevel,
385) -> Result<Box<dyn Write + Send>> {
386    Err(Error::FeatureDisabled("gzip"))
387}
388
389#[cfg(feature = "gzip")]
390fn bgzf_writer<W: Write + Send + 'static>(
391    sink: W,
392    level: CompressionLevel,
393    blocks_per_batch: Option<usize>,
394) -> Result<Box<dyn Write + Send>> {
395    let mut writer = crate::bgzf::BgzfWriter::with_level(sink, level);
396    if let Some(blocks) = blocks_per_batch {
397        writer = writer.blocks_per_batch(blocks);
398    }
399    Ok(Box::new(writer))
400}
401
402#[cfg(not(feature = "gzip"))]
403fn bgzf_writer<W: Write + Send + 'static>(
404    _sink: W,
405    _level: CompressionLevel,
406    _blocks_per_batch: Option<usize>,
407) -> Result<Box<dyn Write + Send>> {
408    Err(Error::FeatureDisabled("gzip"))
409}
410
411/// Zstandard output.
412///
413/// `auto_finish` matters: a zstd frame needs an epilogue, and without it the
414/// file is truncated. This mirrors how flate2's encoder finishes on drop, so
415/// both compressed paths behave the same whether the caller calls `finish` or
416/// simply drops the writer.
417#[cfg(feature = "zstd")]
418fn zstd_writer<W: Write + Send + 'static>(
419    sink: W,
420    level: CompressionLevel,
421) -> Result<Box<dyn Write + Send>> {
422    // gzip levels run 0-9, zstd 1-22. Map the level across rather than passing
423    // a gzip number to zstd, where 9 would be a middling setting.
424    let zstd_level = match level.0 {
425        0 => 1,
426        level => (level.min(9) as i32 - 1) * 21 / 8 + 1,
427    };
428    let encoder = zstd::stream::write::Encoder::new(sink, zstd_level)?;
429    Ok(Box::new(encoder.auto_finish()))
430}
431
432#[cfg(not(feature = "zstd"))]
433fn zstd_writer<W: Write + Send + 'static>(
434    _sink: W,
435    _level: CompressionLevel,
436) -> Result<Box<dyn Write + Send>> {
437    Err(Error::FeatureDisabled("zstd"))
438}
439
440/// The boxed writer type produced by [`create`] and [`WriterBuilder::stdout`].
441pub type BoxedWriter = FastxWriter<Box<dyn Write + Send>>;
442
443/// Create a sequence file, inferring format and gzip compression from the path.
444///
445/// ```no_run
446/// let mut writer = fastx::create("contigs.fasta")?;
447/// writer.write_record(&fastx::Sequence::fasta("contig1", b"ACGT"))?;
448/// writer.finish()?;
449/// # Ok::<(), fastx::Error>(())
450/// ```
451pub fn create<P: AsRef<Path>>(path: P) -> Result<BoxedWriter> {
452    WriterBuilder::default().create(path)
453}
454
455/// Write records to standard output in `format`.
456pub fn stdout(format: Format) -> Result<BoxedWriter> {
457    WriterBuilder::default().format(format).stdout()
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::reader::FastxReader;
464
465    fn write_to_string(format: Format, records: &[Sequence], width: usize) -> String {
466        let mut out = Vec::new();
467        let mut writer = FastxWriter::new(&mut out, format).line_width(width);
468        writer.write_all(records).unwrap();
469        writer.flush().unwrap();
470        String::from_utf8(out).unwrap()
471    }
472
473    #[test]
474    fn writes_wrapped_fasta() {
475        let records = vec![Sequence::fasta("a", b"ACGTACGTAC").with_description("d")];
476        assert_eq!(
477            write_to_string(Format::Fasta, &records, 4),
478            ">a d\nACGT\nACGT\nAC\n"
479        );
480        assert_eq!(
481            write_to_string(Format::Fasta, &records, 0),
482            ">a d\nACGTACGTAC\n"
483        );
484    }
485
486    #[test]
487    fn drops_quality_when_writing_fasta() {
488        let records = vec![Sequence::fastq("a", b"ACGT", b"IIII").unwrap()];
489        assert_eq!(write_to_string(Format::Fasta, &records, 60), ">a\nACGT\n");
490    }
491
492    #[test]
493    fn refuses_fastq_without_quality() {
494        let mut out = Vec::new();
495        let mut writer = FastxWriter::new(&mut out, Format::Fastq);
496        let err = writer
497            .write_record(&Sequence::fasta("a", b"ACGT"))
498            .unwrap_err();
499        assert!(matches!(err, Error::MissingQuality { .. }));
500    }
501
502    #[test]
503    fn validates_on_demand() {
504        let mut out = Vec::new();
505        let mut writer = FastxWriter::new(&mut out, Format::Fasta).validate(Alphabet::Dna);
506        assert!(writer.write_record(&Sequence::fasta("a", b"ACGT")).is_ok());
507        let err = writer
508            .write_record(&Sequence::fasta("b", b"ACGX"))
509            .unwrap_err();
510        assert!(matches!(err, Error::InvalidByte { byte: b'X', .. }));
511        assert_eq!(writer.records_written(), 1);
512    }
513
514    #[test]
515    fn writes_parts_without_records() {
516        let mut out = Vec::new();
517        let mut writer = FastxWriter::new(&mut out, Format::Fastq);
518        writer
519            .write_fastq("r1", Some("desc"), b"ACGT", b"IIII")
520            .unwrap();
521        writer.write_fasta("r2", None, b"TTTT").unwrap();
522        writer.flush().unwrap();
523        assert_eq!(out, b"@r1 desc\nACGT\n+\nIIII\n>r2\nTTTT\n");
524
525        let mut out = Vec::new();
526        let mut writer = FastxWriter::new(&mut out, Format::Fastq);
527        assert!(writer.write_fastq("r", None, b"ACGT", b"II").is_err());
528    }
529
530    #[test]
531    fn round_trips_through_reader() {
532        let original: Vec<Sequence> = (0..50)
533            .map(|i| {
534                Sequence::fastq(
535                    format!("read{i}"),
536                    "ACGTN".repeat(i + 1).into_bytes(),
537                    "IIIII".repeat(i + 1).into_bytes(),
538                )
539                .unwrap()
540                .with_description(format!("record number {i}"))
541            })
542            .collect();
543
544        let text = write_to_string(Format::Fastq, &original, 60);
545        let parsed: Vec<Sequence> = FastxReader::new(text.as_bytes())
546            .collect::<Result<Vec<_>>>()
547            .unwrap();
548        assert_eq!(parsed, original);
549
550        // FASTA round trip re-joins wrapped lines.
551        let fasta_input: Vec<Sequence> =
552            original.iter().cloned().map(Sequence::into_fasta).collect();
553        let text = write_to_string(Format::Fasta, &fasta_input, 7);
554        let parsed: Vec<Sequence> = FastxReader::new(text.as_bytes())
555            .collect::<Result<Vec<_>>>()
556            .unwrap();
557        assert_eq!(parsed, fasta_input);
558    }
559
560    #[test]
561    fn empty_sequence_round_trips() {
562        let records = vec![
563            Sequence::fasta("a", Vec::new()),
564            Sequence::fasta("b", b"AC"),
565        ];
566        let text = write_to_string(Format::Fasta, &records, 60);
567        assert_eq!(text, ">a\n\n>b\nAC\n");
568        let parsed: Vec<Sequence> = FastxReader::new(text.as_bytes())
569            .collect::<Result<Vec<_>>>()
570            .unwrap();
571        assert_eq!(parsed, records);
572    }
573
574    #[test]
575    fn builder_requires_a_format() {
576        let mut out = Vec::new();
577        assert!(matches!(
578            WriterBuilder::default().build(&mut out),
579            Err(Error::UnknownFormat { .. })
580        ));
581        assert!(WriterBuilder::default()
582            .format(Format::Fasta)
583            .build(&mut out)
584            .is_ok());
585    }
586}