Skip to main content

readcon_core/
compression.rs

1//=============================================================================
2// Transparent compression support
3//=============================================================================
4
5use std::io::{self, Read};
6use std::path::Path;
7
8/// Detected compression format based on magic bytes.
9///
10/// `Zstd` is only constructed when the `zstd` Cargo feature is enabled.
11/// Builds without the feature treat `.zst` files as opaque bytes and
12/// return an error from [`read_file_contents`] indicating the feature
13/// is required.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Compression {
16    None,
17    Gzip,
18    /// zstd frame, magic `28 B5 2F FD`. Build with `--features zstd`.
19    Zstd,
20}
21
22/// Detect compression format from the first bytes of a file.
23///
24/// - `1f 8b` = gzip
25/// - `28 b5 2f fd` = zstd
26/// - Otherwise = uncompressed
27pub fn detect_compression(bytes: &[u8]) -> Compression {
28    if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b {
29        return Compression::Gzip;
30    }
31    if bytes.len() >= 4
32        && bytes[0] == 0x28
33        && bytes[1] == 0xb5
34        && bytes[2] == 0x2f
35        && bytes[3] == 0xfd
36    {
37        return Compression::Zstd;
38    }
39    Compression::None
40}
41
42/// Detect compression format from a file extension.
43///
44/// Returns `Compression::Gzip` for `.gz`, `Compression::Zstd` for
45/// `.zst`, `Compression::None` otherwise.
46pub fn detect_compression_from_extension(path: &Path) -> Compression {
47    match path.extension().and_then(|e| e.to_str()) {
48        Some("gz") => Compression::Gzip,
49        Some("zst") => Compression::Zstd,
50        _ => Compression::None,
51    }
52}
53
54/// Size threshold below which we use `read_to_string` instead of mmap.
55const MMAP_THRESHOLD: u64 = 64 * 1024;
56
57/// Reads file contents, decompressing if needed.
58///
59/// Detection strategy:
60/// 1. Read first 2 bytes to check magic bytes.
61/// 2. If gzip: decompress entire file to a String.
62/// 3. If uncompressed and < 64 KiB: `read_to_string`.
63/// 4. If uncompressed and >= 64 KiB: memory-mapped I/O.
64pub fn read_file_contents(path: &Path) -> Result<FileContents, Box<dyn std::error::Error>> {
65    let file = std::fs::File::open(path)?;
66    let metadata = file.metadata()?;
67
68    // Read first 4 bytes for magic detection (gzip needs 2, zstd needs 4)
69    let mut magic = [0u8; 4];
70    let bytes_read = {
71        let mut f = &file;
72        f.read(&mut magic)?
73    };
74
75    let compression = if bytes_read > 0 {
76        detect_compression(&magic[..bytes_read])
77    } else {
78        Compression::None
79    };
80
81    match compression {
82        Compression::Gzip => {
83            // Re-open and decompress the entire file
84            let file = std::fs::File::open(path)?;
85            // Every gzip member, in order (CON-compress.1, D16).
86            let mut decoder = flate2::read::MultiGzDecoder::new(file);
87            let mut contents = String::new();
88            decoder.read_to_string(&mut contents)?;
89            Ok(FileContents::Owned(contents))
90        }
91        Compression::Zstd => {
92            #[cfg(feature = "zstd")]
93            {
94                let file = std::fs::File::open(path)?;
95                let mut decoder = zstd::stream::read::Decoder::new(file)?;
96                let mut contents = String::new();
97                decoder.read_to_string(&mut contents)?;
98                Ok(FileContents::Owned(contents))
99            }
100            #[cfg(not(feature = "zstd"))]
101            {
102                Err(io::Error::new(
103                    io::ErrorKind::Unsupported,
104                    "zstd-compressed input detected; rebuild readcon-core with --features zstd",
105                )
106                .into())
107            }
108        }
109        Compression::None => {
110            if metadata.len() < MMAP_THRESHOLD {
111                let contents = std::fs::read_to_string(path)?;
112                Ok(FileContents::Owned(contents))
113            } else {
114                let file = std::fs::File::open(path)?;
115                let mmap = unsafe { memmap2::Mmap::map(&file)? };
116                Ok(FileContents::Mapped(mmap))
117            }
118        }
119    }
120}
121
122/// Holds file contents either as an owned String or a memory-mapped region.
123pub enum FileContents {
124    Owned(String),
125    Mapped(memmap2::Mmap),
126}
127
128impl FileContents {
129    pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
130        match self {
131            FileContents::Owned(s) => Ok(s.as_str()),
132            FileContents::Mapped(m) => std::str::from_utf8(m),
133        }
134    }
135
136    /// Like [`Self::as_str`], but validates mapped files in newline-aligned
137    /// slices (in parallel with the `parallel` feature).
138    pub fn as_str_sliced(&self) -> Result<&str, std::str::Utf8Error> {
139        match self {
140            FileContents::Owned(s) => Ok(s.as_str()),
141            FileContents::Mapped(m) => crate::frame_index::validate_utf8_sliced(m),
142        }
143    }
144}
145
146/// Creates a gzip-compressed writer wrapping a file at the given path.
147pub fn gzip_writer(path: &Path) -> io::Result<flate2::write::GzEncoder<std::fs::File>> {
148    let file = std::fs::File::create(path)?;
149    Ok(flate2::write::GzEncoder::new(
150        file,
151        flate2::Compression::default(),
152    ))
153}
154
155/// Creates a zstd-compressed writer wrapping a file at the given path.
156///
157/// Returns a finished writer ready for streaming line writes; the caller
158/// is responsible for dropping the writer to flush the final frame.
159/// Available only when the `zstd` Cargo feature is enabled.
160#[cfg(feature = "zstd")]
161pub fn zstd_writer(
162    path: &Path,
163) -> io::Result<zstd::stream::write::AutoFinishEncoder<'static, std::fs::File>> {
164    let file = std::fs::File::create(path)?;
165    // Level 3 is the zstd CLI default; balances ratio against speed.
166    let encoder = zstd::stream::write::Encoder::new(file, 3)?;
167    Ok(encoder.auto_finish())
168}