Skip to main content

fastx/
format.rs

1//! Format and compression detection.
2
3use std::fmt;
4use std::path::Path;
5
6/// A sequence file format.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum Format {
9    /// FASTA: `>id description` followed by one or more sequence lines.
10    Fasta,
11    /// FASTQ: `@id description`, sequence, `+`, quality.
12    Fastq,
13}
14
15impl Format {
16    /// Recognised FASTA extensions (without the leading dot).
17    pub const FASTA_EXTENSIONS: &'static [&'static str] = &[
18        "fa", "fasta", "fna", "faa", "ffn", "frn", "fas", "mpfa", "seq",
19    ];
20
21    /// Recognised FASTQ extensions (without the leading dot).
22    pub const FASTQ_EXTENSIONS: &'static [&'static str] = &["fq", "fastq"];
23
24    /// Infer the format from a file extension, ignoring a trailing `.gz`/`.bgz`/`.z`.
25    ///
26    /// ```
27    /// use fastx::Format;
28    /// assert_eq!(Format::from_path("reads.fq.gz"), Some(Format::Fastq));
29    /// assert_eq!(Format::from_path("genome.fna"), Some(Format::Fasta));
30    /// assert_eq!(Format::from_path("notes.txt"), None);
31    /// ```
32    pub fn from_path<P: AsRef<Path>>(path: P) -> Option<Format> {
33        let path = path.as_ref();
34        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
35        if matches!(ext.as_str(), "gz" | "bgz" | "gzip" | "z" | "zst" | "bz2") {
36            let stem = path.file_stem()?;
37            return Format::from_path(Path::new(stem));
38        }
39        Format::from_extension(&ext)
40    }
41
42    /// Infer the format from a bare extension such as `"fasta"`.
43    pub fn from_extension(ext: &str) -> Option<Format> {
44        let ext = ext.trim_start_matches('.').to_ascii_lowercase();
45        if Format::FASTA_EXTENSIONS.contains(&ext.as_str()) {
46            Some(Format::Fasta)
47        } else if Format::FASTQ_EXTENSIONS.contains(&ext.as_str()) {
48            Some(Format::Fastq)
49        } else {
50            None
51        }
52    }
53
54    /// Infer the format from the first meaningful byte of a stream.
55    ///
56    /// ```
57    /// use fastx::Format;
58    /// assert_eq!(Format::from_first_byte(b'>'), Some(Format::Fasta));
59    /// assert_eq!(Format::from_first_byte(b'@'), Some(Format::Fastq));
60    /// ```
61    pub fn from_first_byte(byte: u8) -> Option<Format> {
62        match byte {
63            b'>' | b';' => Some(Format::Fasta),
64            b'@' => Some(Format::Fastq),
65            _ => None,
66        }
67    }
68
69    /// The byte that starts a record header in this format.
70    pub const fn header_byte(self) -> u8 {
71        match self {
72            Format::Fasta => b'>',
73            Format::Fastq => b'@',
74        }
75    }
76
77    /// The canonical extension used when creating files.
78    pub const fn extension(self) -> &'static str {
79        match self {
80            Format::Fasta => "fasta",
81            Format::Fastq => "fastq",
82        }
83    }
84
85    /// Whether records in this format carry per-base quality scores.
86    pub const fn has_quality(self) -> bool {
87        matches!(self, Format::Fastq)
88    }
89}
90
91impl fmt::Display for Format {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Format::Fasta => f.write_str("FASTA"),
95            Format::Fastq => f.write_str("FASTQ"),
96        }
97    }
98}
99
100/// Compression applied to a sequence file.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
102pub enum Compression {
103    /// Plain, uncompressed bytes.
104    #[default]
105    None,
106    /// One deflate stream, as `gzip` produces. Readable start to finish only.
107    Gzip,
108    /// Block-compressed gzip, as `bgzip` produces.
109    ///
110    /// Valid gzip that any tool can decompress, but split into independent
111    /// members so that a reader with a `.gzi` index can seek into it. Costs a
112    /// percent or two of compression ratio and is what the samtools ecosystem
113    /// expects, so it is the default for compressed output.
114    Bgzf,
115}
116
117impl Compression {
118    /// The first two bytes of a gzip member.
119    pub const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
120
121    /// Infer compression from a file extension.
122    pub fn from_path<P: AsRef<Path>>(path: P) -> Compression {
123        match path
124            .as_ref()
125            .extension()
126            .and_then(|e| e.to_str())
127            .map(|e| e.to_ascii_lowercase())
128            .as_deref()
129        {
130            Some("gz") | Some("bgz") | Some("gzip") => Compression::Gzip,
131            _ => Compression::None,
132        }
133    }
134
135    /// Infer compression from the leading bytes of a stream.
136    pub fn from_magic(bytes: &[u8]) -> Compression {
137        if bytes.len() >= 2 && bytes[..2] == Compression::GZIP_MAGIC {
138            Compression::Gzip
139        } else {
140            Compression::None
141        }
142    }
143}
144
145/// gzip compression level used when writing.
146///
147/// The level dominates the cost of writing compressed output — far more than
148/// parsing does. On an 81 MiB FASTQ, one run took 2.8 s at level 1 and 18.6 s at
149/// level 6, for output of 42.2 MB versus 38.9 MB: **6.5× the time to save 8%**.
150/// [`CompressionLevel::FAST`] is almost always the right choice for files that
151/// another pipeline stage will immediately read back.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct CompressionLevel(pub u32);
154
155impl CompressionLevel {
156    /// No compression, fastest. Still a valid gzip stream.
157    pub const NONE: CompressionLevel = CompressionLevel(0);
158    /// Fast, slightly larger output — what pipeline intermediates want.
159    pub const FAST: CompressionLevel = CompressionLevel(1);
160    /// gzip's own default, and this crate's, for consistency with other tools.
161    pub const DEFAULT: CompressionLevel = CompressionLevel(6);
162    /// Smallest output, slowest — for data you write once and archive.
163    pub const BEST: CompressionLevel = CompressionLevel(9);
164}
165
166impl Default for CompressionLevel {
167    fn default() -> Self {
168        CompressionLevel::DEFAULT
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn detects_format_from_extensions() {
178        assert_eq!(Format::from_path("a.fa"), Some(Format::Fasta));
179        assert_eq!(Format::from_path("a.FASTA"), Some(Format::Fasta));
180        assert_eq!(Format::from_path("a.faa"), Some(Format::Fasta));
181        assert_eq!(Format::from_path("/tmp/x/a.fna.gz"), Some(Format::Fasta));
182        assert_eq!(Format::from_path("a.fq"), Some(Format::Fastq));
183        assert_eq!(Format::from_path("a.fastq.gz"), Some(Format::Fastq));
184        assert_eq!(Format::from_path("a.gz"), None);
185        assert_eq!(Format::from_path("a"), None);
186    }
187
188    #[test]
189    fn detects_compression() {
190        assert_eq!(Compression::from_path("a.fq.gz"), Compression::Gzip);
191        assert_eq!(Compression::from_path("a.fq"), Compression::None);
192        assert_eq!(
193            Compression::from_magic(&[0x1f, 0x8b, 0x08]),
194            Compression::Gzip
195        );
196        assert_eq!(Compression::from_magic(b">seq"), Compression::None);
197        assert_eq!(Compression::from_magic(&[0x1f]), Compression::None);
198    }
199}