Skip to main content

ipfrs_storage/
compression_pipeline.rs

1//! `StorageCompressionPipeline` — configurable multi-stage compression pipeline.
2//!
3//! Selects and applies compression algorithms based on content type, size heuristics,
4//! and compression ratio targets. All algorithms are implemented in pure Rust with no
5//! external compression crates.
6//!
7//! # Algorithms
8//! - **None** — identity passthrough
9//! - **Rle** — Run-Length Encoding (consecutive byte pairs)
10//! - **Lz4** — LZ77-style sliding-window compressor (simplified, round-trip only)
11//! - **Zstd** — dispatches to RLE (level ≤ 3) or LZ4-style (level ≥ 4)
12//! - **Snappy** — synonym for the LZ4-style algorithm
13
14use std::collections::HashMap;
15
16// ─────────────────────────────────────────────────────────────────────────────
17// Error
18// ─────────────────────────────────────────────────────────────────────────────
19
20/// Errors that can arise during decompression or algorithm dispatch.
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22pub enum CompressionError {
23    /// The compressed stream could not be decoded.
24    #[error("decompression failed: {0}")]
25    DecompressionFailed(String),
26    /// The requested algorithm name is not recognised.
27    #[error("unknown algorithm: {0}")]
28    UnknownAlgo(String),
29    /// The compressed payload is structurally corrupt.
30    #[error("corrupted data")]
31    CorruptedData,
32}
33
34// ─────────────────────────────────────────────────────────────────────────────
35// CompressionAlgo
36// ─────────────────────────────────────────────────────────────────────────────
37
38/// Compression algorithm used (or to be used) by a pipeline stage.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum CompressionAlgo {
41    /// No compression — data is stored as-is.
42    None,
43    /// LZ77-style sliding-window compressor (pure Rust, simplified).
44    Lz4,
45    /// Zstandard-inspired: dispatches to RLE for level ≤ 3, LZ4-style for level ≥ 4.
46    Zstd {
47        /// Compression level (1 = fastest/worst, 22 = slowest/best).
48        level: i32,
49    },
50    /// Snappy-inspired: same algorithm as [`Lz4`](Self::Lz4).
51    Snappy,
52    /// Run-Length Encoding — pairs of (count, byte).
53    Rle,
54}
55
56impl CompressionAlgo {
57    /// A stable, human-readable name for this algorithm (used as map key).
58    pub fn name(&self) -> String {
59        match self {
60            Self::None => "none".to_string(),
61            Self::Lz4 => "lz4".to_string(),
62            Self::Zstd { level } => format!("zstd({})", level),
63            Self::Snappy => "snappy".to_string(),
64            Self::Rle => "rle".to_string(),
65        }
66    }
67}
68
69// ─────────────────────────────────────────────────────────────────────────────
70// CompressionHint
71// ─────────────────────────────────────────────────────────────────────────────
72
73/// Content-type hint used by the pipeline to bias algorithm selection.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum CompressionHint {
76    /// Human-readable text (valid UTF-8).
77    Text,
78    /// Structured data (JSON / CBOR-like; first byte is `{` or `[`).
79    Structured,
80    /// Arbitrary binary data.
81    Binary,
82    /// Unknown content — detect automatically.
83    Unknown,
84}
85
86// ─────────────────────────────────────────────────────────────────────────────
87// CompressionResult
88// ─────────────────────────────────────────────────────────────────────────────
89
90/// Output of a single compression operation.
91#[derive(Debug, Clone)]
92pub struct CompressionResult {
93    /// The algorithm that was applied.
94    pub algo: CompressionAlgo,
95    /// Size of the input data in bytes.
96    pub original_size: usize,
97    /// Size of the compressed output in bytes.
98    pub compressed_size: usize,
99    /// `compressed_size / original_size` (lower is better; 1.0 = no saving).
100    pub ratio: f64,
101    /// The compressed payload.
102    pub data: Vec<u8>,
103}
104
105impl CompressionResult {
106    fn new(algo: CompressionAlgo, original_size: usize, data: Vec<u8>) -> Self {
107        let compressed_size = data.len();
108        let ratio = if original_size == 0 {
109            1.0
110        } else {
111            compressed_size as f64 / original_size as f64
112        };
113        Self {
114            algo,
115            original_size,
116            compressed_size,
117            ratio,
118            data,
119        }
120    }
121}
122
123// ─────────────────────────────────────────────────────────────────────────────
124// PipelineStage
125// ─────────────────────────────────────────────────────────────────────────────
126
127/// A single stage inside a [`PipelineConfig`].
128///
129/// The stage is attempted only when both size and ratio pre-conditions are met.
130#[derive(Debug, Clone)]
131pub struct PipelineStage {
132    /// The compression algorithm for this stage.
133    pub algo: CompressionAlgo,
134    /// Minimum input size (bytes) required before this stage is tried.
135    pub min_input_size: usize,
136    /// Maximum acceptable ratio (`compressed / original`).  If the actual ratio
137    /// exceeds this threshold the stage result is discarded.
138    pub max_ratio: f64,
139}
140
141// ─────────────────────────────────────────────────────────────────────────────
142// PipelineConfig
143// ─────────────────────────────────────────────────────────────────────────────
144
145/// Runtime configuration for [`StorageCompressionPipeline`].
146///
147/// Re-exported as `CpPipelineConfig` from `lib.rs` to avoid collision with
148/// `deduplication_pipeline::PipelineConfig`.
149#[derive(Debug, Clone)]
150pub struct PipelineConfig {
151    /// Ordered list of stages to try in sequence.
152    pub stages: Vec<PipelineStage>,
153    /// Algorithm used when no stage accepts the data.
154    pub fallback_algo: CompressionAlgo,
155    /// Global target ratio.  Used for statistics but does not gate individual stages.
156    pub target_ratio: f64,
157    /// When `true`, stages whose ratio exceeds `stage.max_ratio` are skipped.
158    pub enable_ratio_check: bool,
159}
160
161impl Default for PipelineConfig {
162    fn default() -> Self {
163        Self {
164            stages: vec![
165                PipelineStage {
166                    algo: CompressionAlgo::Rle,
167                    min_input_size: 64,
168                    max_ratio: 0.9,
169                },
170                PipelineStage {
171                    algo: CompressionAlgo::Lz4,
172                    min_input_size: 256,
173                    max_ratio: 0.85,
174                },
175                PipelineStage {
176                    algo: CompressionAlgo::Zstd { level: 3 },
177                    min_input_size: 512,
178                    max_ratio: 0.7,
179                },
180            ],
181            fallback_algo: CompressionAlgo::None,
182            target_ratio: 0.75,
183            enable_ratio_check: true,
184        }
185    }
186}
187
188// ─────────────────────────────────────────────────────────────────────────────
189// PipelineStats
190// ─────────────────────────────────────────────────────────────────────────────
191
192/// Aggregated statistics for a [`StorageCompressionPipeline`] instance.
193#[derive(Debug, Clone)]
194pub struct PipelineStats {
195    /// Number of compress calls executed.
196    pub total_compressed: u64,
197    /// Total raw bytes fed into the pipeline.
198    pub total_bytes_in: u64,
199    /// Total compressed bytes emitted by the pipeline.
200    pub total_bytes_out: u64,
201    /// Overall average ratio (`total_bytes_out / total_bytes_in`).
202    pub avg_ratio: f64,
203    /// Per-algorithm usage counters (key = `CompressionAlgo::name()`).
204    pub algo_usage: HashMap<String, u64>,
205}
206
207// ─────────────────────────────────────────────────────────────────────────────
208// Pure-Rust algorithm implementations
209// ─────────────────────────────────────────────────────────────────────────────
210
211// ── RLE ──────────────────────────────────────────────────────────────────────
212
213/// Compress `data` using Run-Length Encoding.
214///
215/// Emits (count: u8, byte: u8) pairs.  Each run is capped at 255 bytes.
216fn rle_compress(data: &[u8]) -> Vec<u8> {
217    if data.is_empty() {
218        return Vec::new();
219    }
220    let mut out = Vec::with_capacity(data.len());
221    let mut i = 0;
222    while i < data.len() {
223        let byte = data[i];
224        let mut count: usize = 1;
225        while i + count < data.len() && data[i + count] == byte && count < 255 {
226            count += 1;
227        }
228        out.push(count as u8);
229        out.push(byte);
230        i += count;
231    }
232    out
233}
234
235/// Decompress an RLE stream produced by [`rle_compress`].
236fn rle_decompress(data: &[u8]) -> Result<Vec<u8>, CompressionError> {
237    if !data.len().is_multiple_of(2) {
238        return Err(CompressionError::CorruptedData);
239    }
240    let mut out = Vec::with_capacity(data.len() * 2);
241    let mut i = 0;
242    while i + 1 < data.len() {
243        let count = data[i] as usize;
244        let byte = data[i + 1];
245        if count == 0 {
246            return Err(CompressionError::DecompressionFailed(
247                "zero-length RLE run".to_string(),
248            ));
249        }
250        for _ in 0..count {
251            out.push(byte);
252        }
253        i += 2;
254    }
255    Ok(out)
256}
257
258// ── LZ4-style (LZ77 sliding window) ─────────────────────────────────────────
259
260/// Window size for the LZ4-style compressor.
261const LZ4_WINDOW: usize = 4096;
262/// Minimum match length (bytes) to emit a back-reference.
263const LZ4_MIN_MATCH: usize = 4;
264
265/// FNV-1a hash of a 4-byte sequence (used by the LZ4-style hash table).
266#[inline]
267fn fnv1a_4(data: &[u8]) -> u32 {
268    const OFFSET: u32 = 2_166_136_261;
269    const PRIME: u32 = 16_777_619;
270    let mut h = OFFSET;
271    for &b in data.iter().take(4) {
272        h ^= u32::from(b);
273        h = h.wrapping_mul(PRIME);
274    }
275    h
276}
277
278/// Compress `data` using an LZ77-style sliding-window algorithm.
279///
280/// Output format: a sequence of tokens, where each token is:
281/// - `offset` (u16 LE) + `length` (u8): back-reference — `length > 0` and `offset > 0`
282/// - `0u16` + `0u8` + one literal byte: literal token
283fn lz4_style_compress(data: &[u8]) -> Vec<u8> {
284    if data.is_empty() {
285        return Vec::new();
286    }
287
288    // Hash table: key = fnv1a_4(seq) % TABLE_SIZE → last position seen
289    const TABLE_SIZE: usize = 4096;
290    let mut table: [usize; TABLE_SIZE] = [usize::MAX; TABLE_SIZE];
291
292    let mut out = Vec::with_capacity(data.len());
293    let mut pos = 0;
294
295    while pos < data.len() {
296        // Need at least 4 bytes for a potential match
297        if pos + LZ4_MIN_MATCH <= data.len() {
298            let seq = &data[pos..pos + 4];
299            let idx = fnv1a_4(seq) as usize % TABLE_SIZE;
300            let candidate = table[idx];
301            table[idx] = pos;
302
303            if candidate != usize::MAX && candidate < pos {
304                let offset = pos - candidate;
305                if offset <= LZ4_WINDOW {
306                    // Measure match length
307                    let max_len = (data.len() - pos).min(255);
308                    let mut match_len = 0;
309                    while match_len < max_len
310                        && data[candidate + match_len] == data[pos + match_len]
311                    {
312                        match_len += 1;
313                    }
314
315                    if match_len >= LZ4_MIN_MATCH {
316                        // Emit back-reference token
317                        let off = offset as u16;
318                        out.extend_from_slice(&off.to_le_bytes());
319                        out.push(match_len as u8);
320                        pos += match_len;
321                        continue;
322                    }
323                }
324            }
325        }
326
327        // Emit literal token
328        out.extend_from_slice(&0u16.to_le_bytes());
329        out.push(0u8);
330        out.push(data[pos]);
331        pos += 1;
332    }
333
334    out
335}
336
337/// Decompress a stream produced by [`lz4_style_compress`].
338fn lz4_style_decompress(data: &[u8]) -> Result<Vec<u8>, CompressionError> {
339    let mut out: Vec<u8> = Vec::with_capacity(data.len() * 3);
340    let mut i = 0;
341
342    while i + 2 < data.len() {
343        let offset = u16::from_le_bytes(
344            data[i..i + 2]
345                .try_into()
346                .map_err(|_| CompressionError::CorruptedData)?,
347        ) as usize;
348        let length = data[i + 2] as usize;
349        i += 3;
350
351        if length == 0 {
352            // Literal token: consume one byte
353            if i >= data.len() {
354                return Err(CompressionError::DecompressionFailed(
355                    "truncated literal token".to_string(),
356                ));
357            }
358            out.push(data[i]);
359            i += 1;
360        } else {
361            // Back-reference token
362            if offset == 0 {
363                return Err(CompressionError::DecompressionFailed(
364                    "zero offset in back-reference".to_string(),
365                ));
366            }
367            let start = out
368                .len()
369                .checked_sub(offset)
370                .ok_or(CompressionError::CorruptedData)?;
371            for k in 0..length {
372                let b = out
373                    .get(start + k)
374                    .copied()
375                    .ok_or(CompressionError::CorruptedData)?;
376                out.push(b);
377            }
378        }
379    }
380
381    // Any remaining bytes that do not form a complete token are silently ignored
382    // (this can happen if the stream ends without a literal after a token header —
383    // in practice our encoder never produces this).
384    Ok(out)
385}
386
387// ─────────────────────────────────────────────────────────────────────────────
388// StorageCompressionPipeline
389// ─────────────────────────────────────────────────────────────────────────────
390
391/// Multi-stage compression pipeline with content-aware algorithm selection.
392///
393/// # Example
394/// ```rust
395/// use ipfrs_storage::compression_pipeline::{
396///     StorageCompressionPipeline, PipelineConfig, CompressionHint,
397/// };
398///
399/// let mut pipeline = StorageCompressionPipeline::new(PipelineConfig::default());
400/// let data = b"aaaaaabbbbbbcccccccccdddddddddeeeeeeeeeee";
401/// let result = pipeline.compress(data, CompressionHint::Binary);
402/// let recovered = pipeline.decompress(&result.data, &result.algo).unwrap();
403/// assert_eq!(&recovered, data);
404/// ```
405#[derive(Debug)]
406pub struct StorageCompressionPipeline {
407    /// Pipeline configuration.
408    pub config: PipelineConfig,
409    /// Number of successful compress calls.
410    pub total_compressed: u64,
411    /// Total raw bytes fed into the pipeline.
412    pub total_bytes_in: u64,
413    /// Total compressed bytes emitted by the pipeline.
414    pub total_bytes_out: u64,
415    /// Per-algorithm usage counters.
416    pub algo_counters: HashMap<String, u64>,
417}
418
419impl StorageCompressionPipeline {
420    /// Create a new pipeline with the supplied configuration.
421    pub fn new(config: PipelineConfig) -> Self {
422        Self {
423            config,
424            total_compressed: 0,
425            total_bytes_in: 0,
426            total_bytes_out: 0,
427            algo_counters: HashMap::new(),
428        }
429    }
430
431    // ── Core methods ─────────────────────────────────────────────────────────
432
433    /// Compress `data` using the first pipeline stage whose conditions are met.
434    ///
435    /// Stages are tried in the order defined in [`PipelineConfig::stages`].  A
436    /// stage is skipped if:
437    /// - `data.len() < stage.min_input_size`, or
438    /// - `config.enable_ratio_check` is `true` and the resulting ratio exceeds
439    ///   `stage.max_ratio`.
440    ///
441    /// If no stage accepts the data, the `fallback_algo` is used.
442    pub fn compress(&mut self, data: &[u8], _hint: CompressionHint) -> CompressionResult {
443        let original_size = data.len();
444
445        let result = self.try_stages(data).unwrap_or_else(|| {
446            let compressed = self.compress_with_algo(data, &self.config.fallback_algo.clone());
447            CompressionResult::new(self.config.fallback_algo.clone(), original_size, compressed)
448        });
449
450        // Update counters
451        self.total_compressed += 1;
452        self.total_bytes_in += original_size as u64;
453        self.total_bytes_out += result.compressed_size as u64;
454        *self.algo_counters.entry(result.algo.name()).or_insert(0) += 1;
455
456        result
457    }
458
459    /// Try each stage in order; return the first acceptable result.
460    fn try_stages(&self, data: &[u8]) -> Option<CompressionResult> {
461        for stage in &self.config.stages {
462            if data.len() < stage.min_input_size {
463                continue;
464            }
465            let compressed = self.compress_with_algo(data, &stage.algo);
466            let ratio = if data.is_empty() {
467                1.0
468            } else {
469                compressed.len() as f64 / data.len() as f64
470            };
471            if !self.config.enable_ratio_check || ratio <= stage.max_ratio {
472                return Some(CompressionResult::new(
473                    stage.algo.clone(),
474                    data.len(),
475                    compressed,
476                ));
477            }
478        }
479        None
480    }
481
482    /// Decompress `data` using the specified algorithm.
483    pub fn decompress(
484        &self,
485        data: &[u8],
486        algo: &CompressionAlgo,
487    ) -> Result<Vec<u8>, CompressionError> {
488        match algo {
489            CompressionAlgo::None => Ok(data.to_vec()),
490            CompressionAlgo::Rle => rle_decompress(data),
491            CompressionAlgo::Lz4 | CompressionAlgo::Snappy => lz4_style_decompress(data),
492            CompressionAlgo::Zstd { level } => {
493                if *level <= 3 {
494                    rle_decompress(data)
495                } else {
496                    lz4_style_decompress(data)
497                }
498            }
499        }
500    }
501
502    /// Compress `data` with a specific algorithm, returning the raw bytes.
503    pub fn compress_with_algo(&self, data: &[u8], algo: &CompressionAlgo) -> Vec<u8> {
504        match algo {
505            CompressionAlgo::None => data.to_vec(),
506            CompressionAlgo::Rle => rle_compress(data),
507            CompressionAlgo::Lz4 | CompressionAlgo::Snappy => lz4_style_compress(data),
508            CompressionAlgo::Zstd { level } => {
509                if *level <= 3 {
510                    rle_compress(data)
511                } else {
512                    lz4_style_compress(data)
513                }
514            }
515        }
516    }
517
518    /// Detect content hint from the first few bytes of `data`.
519    pub fn detect_hint(data: &[u8]) -> CompressionHint {
520        if data.is_empty() {
521            return CompressionHint::Unknown;
522        }
523        if std::str::from_utf8(data).is_ok() {
524            let first = data[0];
525            if first == b'{' || first == b'[' {
526                return CompressionHint::Structured;
527            }
528            return CompressionHint::Text;
529        }
530        CompressionHint::Binary
531    }
532
533    /// Automatically detect the content hint and then compress.
534    pub fn auto_compress(&mut self, data: &[u8]) -> CompressionResult {
535        let hint = Self::detect_hint(data);
536        self.compress(data, hint)
537    }
538
539    /// Return a snapshot of the current pipeline statistics.
540    pub fn pipeline_stats(&self) -> PipelineStats {
541        let avg_ratio = if self.total_bytes_in == 0 {
542            1.0
543        } else {
544            self.total_bytes_out as f64 / self.total_bytes_in as f64
545        };
546        PipelineStats {
547            total_compressed: self.total_compressed,
548            total_bytes_in: self.total_bytes_in,
549            total_bytes_out: self.total_bytes_out,
550            avg_ratio,
551            algo_usage: self.algo_counters.clone(),
552        }
553    }
554}
555
556// ─────────────────────────────────────────────────────────────────────────────
557// Tests
558// ─────────────────────────────────────────────────────────────────────────────
559
560#[cfg(test)]
561mod tests {
562    use std::collections::HashMap;
563
564    use crate::compression_pipeline::{
565        lz4_style_compress, lz4_style_decompress, rle_compress, rle_decompress, CompressionAlgo,
566        CompressionError, CompressionHint, CompressionResult, PipelineConfig, PipelineStage,
567        PipelineStats, StorageCompressionPipeline,
568    };
569
570    // ── Helpers ───────────────────────────────────────────────────────────────
571
572    fn make_pipeline() -> StorageCompressionPipeline {
573        StorageCompressionPipeline::new(PipelineConfig::default())
574    }
575
576    fn repeated_data(byte: u8, count: usize) -> Vec<u8> {
577        vec![byte; count]
578    }
579
580    fn alternating_data(len: usize) -> Vec<u8> {
581        (0..len).map(|i| (i % 256) as u8).collect()
582    }
583
584    // ── RLE unit tests ────────────────────────────────────────────────────────
585
586    #[test]
587    fn test_rle_empty_input() {
588        let compressed = rle_compress(&[]);
589        assert!(compressed.is_empty());
590        let decompressed = rle_decompress(&compressed).unwrap();
591        assert!(decompressed.is_empty());
592    }
593
594    #[test]
595    fn test_rle_single_byte() {
596        let data = [0xABu8];
597        let c = rle_compress(&data);
598        // One run of length 1 → (1, 0xAB)
599        assert_eq!(c, vec![1, 0xAB]);
600        assert_eq!(rle_decompress(&c).unwrap(), data);
601    }
602
603    #[test]
604    fn test_rle_uniform_run() {
605        let data = repeated_data(0x42, 100);
606        let c = rle_compress(&data);
607        // One run capped at 100 (< 255) → 2 bytes
608        assert_eq!(c.len(), 2);
609        assert_eq!(rle_decompress(&c).unwrap(), data);
610    }
611
612    #[test]
613    fn test_rle_max_run_cap() {
614        // 256 identical bytes → two runs: (255, b) + (1, b)
615        let data = repeated_data(0xCC, 256);
616        let c = rle_compress(&data);
617        assert_eq!(c.len(), 4);
618        assert_eq!(c[0], 255);
619        assert_eq!(c[2], 1);
620        assert_eq!(rle_decompress(&c).unwrap(), data);
621    }
622
623    #[test]
624    fn test_rle_round_trip_alternating() {
625        let data: Vec<u8> = (0u8..=127).collect();
626        let c = rle_compress(&data);
627        assert_eq!(rle_decompress(&c).unwrap(), data);
628    }
629
630    #[test]
631    fn test_rle_odd_length_corrupt() {
632        // Odd-length RLE stream must fail
633        let result = rle_decompress(&[1, 2, 3]);
634        assert!(matches!(result, Err(CompressionError::CorruptedData)));
635    }
636
637    #[test]
638    fn test_rle_mixed_runs() {
639        let data = b"aaabbbccc";
640        let c = rle_compress(data);
641        // (3,a)(3,b)(3,c) = 6 bytes
642        assert_eq!(c.len(), 6);
643        assert_eq!(rle_decompress(&c).unwrap().as_slice(), data.as_ref());
644    }
645
646    // ── LZ4-style unit tests ──────────────────────────────────────────────────
647
648    #[test]
649    fn test_lz4_empty_input() {
650        let c = lz4_style_compress(&[]);
651        assert!(c.is_empty());
652        let d = lz4_style_decompress(&c).unwrap();
653        assert!(d.is_empty());
654    }
655
656    #[test]
657    fn test_lz4_single_byte() {
658        let data = [0xFFu8];
659        let c = lz4_style_compress(&data);
660        let d = lz4_style_decompress(&c).unwrap();
661        assert_eq!(d, data);
662    }
663
664    #[test]
665    fn test_lz4_round_trip_short() {
666        let data = b"hello world";
667        let c = lz4_style_compress(data);
668        let d = lz4_style_decompress(&c).unwrap();
669        assert_eq!(d.as_slice(), data.as_ref());
670    }
671
672    #[test]
673    fn test_lz4_round_trip_repeated() {
674        let data = repeated_data(0xAA, 512);
675        let c = lz4_style_compress(&data);
676        let d = lz4_style_decompress(&c).unwrap();
677        assert_eq!(d, data);
678    }
679
680    #[test]
681    fn test_lz4_compresses_repetitive_data() {
682        let data = repeated_data(0xBB, 1024);
683        let c = lz4_style_compress(&data);
684        // Must be smaller than original
685        assert!(c.len() < data.len());
686    }
687
688    #[test]
689    fn test_lz4_round_trip_alternating() {
690        let data = alternating_data(300);
691        let c = lz4_style_compress(&data);
692        let d = lz4_style_decompress(&c).unwrap();
693        assert_eq!(d, data);
694    }
695
696    #[test]
697    fn test_lz4_round_trip_binary() {
698        let data: Vec<u8> = (0..=255u8).cycle().take(800).collect();
699        let c = lz4_style_compress(&data);
700        let d = lz4_style_decompress(&c).unwrap();
701        assert_eq!(d, data);
702    }
703
704    // ── detect_hint ──────────────────────────────────────────────────────────
705
706    #[test]
707    fn test_detect_hint_empty() {
708        assert_eq!(
709            StorageCompressionPipeline::detect_hint(&[]),
710            CompressionHint::Unknown
711        );
712    }
713
714    #[test]
715    fn test_detect_hint_text() {
716        assert_eq!(
717            StorageCompressionPipeline::detect_hint(b"hello world"),
718            CompressionHint::Text
719        );
720    }
721
722    #[test]
723    fn test_detect_hint_structured_object() {
724        assert_eq!(
725            StorageCompressionPipeline::detect_hint(b"{\"key\":\"value\"}"),
726            CompressionHint::Structured
727        );
728    }
729
730    #[test]
731    fn test_detect_hint_structured_array() {
732        assert_eq!(
733            StorageCompressionPipeline::detect_hint(b"[1,2,3]"),
734            CompressionHint::Structured
735        );
736    }
737
738    #[test]
739    fn test_detect_hint_binary() {
740        let data = vec![0xFF, 0xFE, 0xFD, 0x00, 0x01];
741        assert_eq!(
742            StorageCompressionPipeline::detect_hint(&data),
743            CompressionHint::Binary
744        );
745    }
746
747    // ── compress_with_algo round-trips ────────────────────────────────────────
748
749    #[test]
750    fn test_compress_with_algo_none() {
751        let p = make_pipeline();
752        let data = b"no compression";
753        let c = p.compress_with_algo(data, &CompressionAlgo::None);
754        assert_eq!(c.as_slice(), data.as_ref());
755    }
756
757    #[test]
758    fn test_compress_with_algo_rle() {
759        let p = make_pipeline();
760        let data = repeated_data(0x55, 200);
761        let c = p.compress_with_algo(&data, &CompressionAlgo::Rle);
762        let d = p.decompress(&c, &CompressionAlgo::Rle).unwrap();
763        assert_eq!(d, data);
764    }
765
766    #[test]
767    fn test_compress_with_algo_lz4() {
768        let p = make_pipeline();
769        let data = alternating_data(400);
770        let c = p.compress_with_algo(&data, &CompressionAlgo::Lz4);
771        let d = p.decompress(&c, &CompressionAlgo::Lz4).unwrap();
772        assert_eq!(d, data);
773    }
774
775    #[test]
776    fn test_compress_with_algo_snappy() {
777        let p = make_pipeline();
778        let data = repeated_data(0x11, 512);
779        let c = p.compress_with_algo(&data, &CompressionAlgo::Snappy);
780        let d = p.decompress(&c, &CompressionAlgo::Snappy).unwrap();
781        assert_eq!(d, data);
782    }
783
784    #[test]
785    fn test_compress_with_algo_zstd_low() {
786        // level <= 3 → dispatches to RLE
787        let p = make_pipeline();
788        let data = repeated_data(0x22, 300);
789        let c = p.compress_with_algo(&data, &CompressionAlgo::Zstd { level: 2 });
790        let d = p
791            .decompress(&c, &CompressionAlgo::Zstd { level: 2 })
792            .unwrap();
793        assert_eq!(d, data);
794    }
795
796    #[test]
797    fn test_compress_with_algo_zstd_high() {
798        // level >= 4 → dispatches to LZ4-style
799        let p = make_pipeline();
800        let data = alternating_data(600);
801        let c = p.compress_with_algo(&data, &CompressionAlgo::Zstd { level: 5 });
802        let d = p
803            .decompress(&c, &CompressionAlgo::Zstd { level: 5 })
804            .unwrap();
805        assert_eq!(d, data);
806    }
807
808    // ── Pipeline compress / auto_compress ─────────────────────────────────────
809
810    #[test]
811    fn test_compress_small_data_uses_fallback() {
812        // Data smaller than the smallest stage threshold (64) → fallback (None)
813        let mut p = make_pipeline();
814        let data = b"small";
815        let result = p.compress(data, CompressionHint::Binary);
816        assert_eq!(result.algo, CompressionAlgo::None);
817        assert_eq!(result.original_size, data.len());
818    }
819
820    #[test]
821    fn test_compress_medium_data_rle() {
822        // 100 identical bytes should pass the RLE stage (min=64, expected ratio < 0.9)
823        let mut p = make_pipeline();
824        let data = repeated_data(0xAB, 100);
825        let result = p.compress(&data, CompressionHint::Binary);
826        // RLE on uniform data produces 2 bytes → ratio ≈ 0.02 << 0.9
827        assert_eq!(result.algo, CompressionAlgo::Rle);
828    }
829
830    #[test]
831    fn test_compress_round_trip_pipeline() {
832        let mut p = make_pipeline();
833        let data = repeated_data(0xCD, 200);
834        let result = p.compress(&data, CompressionHint::Binary);
835        let recovered = p.decompress(&result.data, &result.algo).unwrap();
836        assert_eq!(recovered, data);
837    }
838
839    #[test]
840    fn test_auto_compress_json() {
841        let mut p = make_pipeline();
842        let json =
843            br#"{"key":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#;
844        let result = p.auto_compress(json);
845        assert_eq!(result.algo, CompressionAlgo::Rle); // JSON starts with '{' → Structured hint, but algo selected by pipeline
846        let recovered = p.decompress(&result.data, &result.algo).unwrap();
847        assert_eq!(recovered.as_slice(), json.as_ref());
848    }
849
850    #[test]
851    fn test_auto_compress_round_trip() {
852        let mut p = make_pipeline();
853        let data = repeated_data(0xEE, 600);
854        let result = p.auto_compress(&data);
855        let recovered = p.decompress(&result.data, &result.algo).unwrap();
856        assert_eq!(recovered, data);
857    }
858
859    #[test]
860    fn test_compress_ratio_field() {
861        let data = repeated_data(0x99, 200);
862        let compressed = rle_compress(&data);
863        let r = CompressionResult::new(CompressionAlgo::Rle, data.len(), compressed.clone());
864        let expected = compressed.len() as f64 / data.len() as f64;
865        assert!((r.ratio - expected).abs() < 1e-12);
866    }
867
868    #[test]
869    fn test_compress_ratio_zero_len() {
870        let r = CompressionResult::new(CompressionAlgo::None, 0, vec![]);
871        assert_eq!(r.ratio, 1.0);
872    }
873
874    // ── Pipeline statistics ───────────────────────────────────────────────────
875
876    #[test]
877    fn test_stats_initial() {
878        let p = make_pipeline();
879        let stats = p.pipeline_stats();
880        assert_eq!(stats.total_compressed, 0);
881        assert_eq!(stats.total_bytes_in, 0);
882        assert_eq!(stats.total_bytes_out, 0);
883        assert_eq!(stats.avg_ratio, 1.0);
884        assert!(stats.algo_usage.is_empty());
885    }
886
887    #[test]
888    fn test_stats_after_compress() {
889        let mut p = make_pipeline();
890        let data = repeated_data(0x77, 200);
891        p.compress(&data, CompressionHint::Binary);
892        let stats = p.pipeline_stats();
893        assert_eq!(stats.total_compressed, 1);
894        assert_eq!(stats.total_bytes_in, 200);
895        assert!(stats.total_bytes_out > 0);
896        assert!(stats.avg_ratio > 0.0);
897    }
898
899    #[test]
900    fn test_stats_algo_counter() {
901        let mut p = make_pipeline();
902        // Compress twice with RLE-friendly data
903        let data = repeated_data(0x33, 200);
904        p.compress(&data, CompressionHint::Binary);
905        p.compress(&data, CompressionHint::Binary);
906        let stats = p.pipeline_stats();
907        // Both should use RLE
908        let rle_count = stats.algo_usage.get("rle").copied().unwrap_or(0);
909        assert_eq!(rle_count, 2);
910    }
911
912    #[test]
913    fn test_stats_multiple_algos() {
914        let mut p = make_pipeline();
915        // Small → fallback (None)
916        p.compress(b"small", CompressionHint::Binary);
917        // Large uniform → RLE
918        p.compress(&repeated_data(0x44, 200), CompressionHint::Binary);
919        let stats = p.pipeline_stats();
920        assert_eq!(stats.total_compressed, 2);
921        assert!(stats.algo_usage.contains_key("none"));
922        assert!(stats.algo_usage.contains_key("rle"));
923    }
924
925    // ── PipelineConfig / PipelineStage ────────────────────────────────────────
926
927    #[test]
928    fn test_custom_config_stages() {
929        let config = PipelineConfig {
930            stages: vec![PipelineStage {
931                algo: CompressionAlgo::Lz4,
932                min_input_size: 10,
933                max_ratio: 0.99,
934            }],
935            fallback_algo: CompressionAlgo::None,
936            target_ratio: 0.8,
937            enable_ratio_check: true,
938        };
939        let mut p = StorageCompressionPipeline::new(config);
940        let data = repeated_data(0x55, 512);
941        let result = p.compress(&data, CompressionHint::Binary);
942        let recovered = p.decompress(&result.data, &result.algo).unwrap();
943        assert_eq!(recovered, data);
944    }
945
946    #[test]
947    fn test_ratio_check_disabled() {
948        // With ratio check disabled, every stage that meets size requirement is accepted
949        let config = PipelineConfig {
950            stages: vec![PipelineStage {
951                algo: CompressionAlgo::Rle,
952                min_input_size: 4,
953                max_ratio: 0.01, // Extremely tight — would be rejected if check is on
954            }],
955            fallback_algo: CompressionAlgo::None,
956            target_ratio: 1.0,
957            enable_ratio_check: false,
958        };
959        let mut p = StorageCompressionPipeline::new(config);
960        let data = b"abcd"; // Not compressible by RLE (4 distinct bytes)
961        let result = p.compress(data, CompressionHint::Binary);
962        // With ratio check disabled and size met, RLE should be used
963        assert_eq!(result.algo, CompressionAlgo::Rle);
964    }
965
966    #[test]
967    fn test_fallback_when_ratio_too_high() {
968        // Only stage has a very tight ratio; random-ish data won't pass → fallback
969        let config = PipelineConfig {
970            stages: vec![PipelineStage {
971                algo: CompressionAlgo::Rle,
972                min_input_size: 1,
973                max_ratio: 0.001, // Nearly impossible to achieve
974            }],
975            fallback_algo: CompressionAlgo::None,
976            target_ratio: 1.0,
977            enable_ratio_check: true,
978        };
979        let mut p = StorageCompressionPipeline::new(config);
980        let data: Vec<u8> = (0u8..=127).collect();
981        let result = p.compress(&data, CompressionHint::Binary);
982        assert_eq!(result.algo, CompressionAlgo::None);
983    }
984
985    // ── CompressionAlgo helpers ───────────────────────────────────────────────
986
987    #[test]
988    fn test_algo_name_none() {
989        assert_eq!(CompressionAlgo::None.name(), "none");
990    }
991
992    #[test]
993    fn test_algo_name_lz4() {
994        assert_eq!(CompressionAlgo::Lz4.name(), "lz4");
995    }
996
997    #[test]
998    fn test_algo_name_zstd() {
999        assert_eq!(CompressionAlgo::Zstd { level: 7 }.name(), "zstd(7)");
1000    }
1001
1002    #[test]
1003    fn test_algo_name_snappy() {
1004        assert_eq!(CompressionAlgo::Snappy.name(), "snappy");
1005    }
1006
1007    #[test]
1008    fn test_algo_name_rle() {
1009        assert_eq!(CompressionAlgo::Rle.name(), "rle");
1010    }
1011
1012    // ── PipelineStats struct ──────────────────────────────────────────────────
1013
1014    #[test]
1015    fn test_pipeline_stats_avg_ratio() {
1016        let mut p = make_pipeline();
1017        let data = repeated_data(0xBB, 1000);
1018        p.compress(&data, CompressionHint::Binary);
1019        let stats = p.pipeline_stats();
1020        let expected = stats.total_bytes_out as f64 / stats.total_bytes_in as f64;
1021        assert!((stats.avg_ratio - expected).abs() < 1e-12);
1022    }
1023
1024    #[test]
1025    fn test_pipeline_stats_type() {
1026        let stats = PipelineStats {
1027            total_compressed: 5,
1028            total_bytes_in: 1000,
1029            total_bytes_out: 500,
1030            avg_ratio: 0.5,
1031            algo_usage: HashMap::new(),
1032        };
1033        assert_eq!(stats.total_compressed, 5);
1034        assert_eq!(stats.avg_ratio, 0.5);
1035    }
1036
1037    // ── Large data round-trips ────────────────────────────────────────────────
1038
1039    #[test]
1040    fn test_large_uniform_rle_round_trip() {
1041        let mut p = make_pipeline();
1042        let data = repeated_data(0xDE, 8192);
1043        let result = p.compress(&data, CompressionHint::Binary);
1044        let recovered = p.decompress(&result.data, &result.algo).unwrap();
1045        assert_eq!(recovered, data);
1046    }
1047
1048    #[test]
1049    fn test_large_alternating_lz4_round_trip() {
1050        let p = make_pipeline();
1051        let data: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
1052        let c = p.compress_with_algo(&data, &CompressionAlgo::Lz4);
1053        let d = p.decompress(&c, &CompressionAlgo::Lz4).unwrap();
1054        assert_eq!(d, data);
1055    }
1056
1057    #[test]
1058    fn test_pipeline_three_calls_stats() {
1059        let mut p = make_pipeline();
1060        for _ in 0..3 {
1061            let data = repeated_data(0xAA, 500);
1062            p.compress(&data, CompressionHint::Binary);
1063        }
1064        let stats = p.pipeline_stats();
1065        assert_eq!(stats.total_compressed, 3);
1066        assert_eq!(stats.total_bytes_in, 1500);
1067    }
1068}