Skip to main content

ipfrs_storage/
storage_compression_pipeline.rs

1//! `StorageCompressionPipeline` — configurable multi-stage compression pipeline.
2//!
3//! Provides a production-quality, pure-Rust compression pipeline with inline
4//! implementations of RLE, LZ77-style sliding window, Delta encoding, and XOR
5//! transform. No external compression crates are used.
6//!
7//! # Quick Start
8//! ```rust
9//! use ipfrs_storage::storage_compression_pipeline::{
10//!     ScpStorageCompressionPipeline, ScpPipelineConfig, CompressionStage,
11//!     ScpCompressionAlgorithm,
12//! };
13//!
14//! let config = ScpPipelineConfig {
15//!     stages: vec![CompressionStage {
16//!         algorithm: ScpCompressionAlgorithm::Rle,
17//!         enabled: true,
18//!         min_size_bytes: 8,
19//!     }],
20//!     max_input_size: 1 << 20,
21//!     enable_checksum: true,
22//! };
23//! let pipeline = ScpStorageCompressionPipeline::new(config);
24//! let data = b"AAAAAABBBCCCCC";
25//! let block = pipeline.compress(data).unwrap();
26//! let recovered = pipeline.decompress(&block).unwrap();
27//! assert_eq!(recovered, data);
28//! ```
29
30use parking_lot::Mutex;
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::Arc;
33
34// ─────────────────────────────────────────────────────────────────────────────
35// FNV-1a 64-bit checksum
36// ─────────────────────────────────────────────────────────────────────────────
37
38/// Compute a FNV-1a 64-bit hash of `data`.
39pub fn fnv1a_64(data: &[u8]) -> u64 {
40    let mut h: u64 = 14_695_981_039_346_656_037;
41    for &b in data {
42        h ^= b as u64;
43        h = h.wrapping_mul(1_099_511_628_211);
44    }
45    h
46}
47
48// ─────────────────────────────────────────────────────────────────────────────
49// CompressionAlgorithm
50// ─────────────────────────────────────────────────────────────────────────────
51
52/// Compression/transform algorithm used by a pipeline stage.
53///
54/// All algorithms are implemented in pure Rust with no external dependencies.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ScpCompressionAlgorithm {
57    /// Identity pass-through — data is stored unchanged.
58    None,
59    /// Run-Length Encoding: encodes consecutive repeated bytes as
60    /// `(count: u8, byte: u8)` pairs. Count is clamped to 255.
61    Rle,
62    /// LZ77-style sliding-window compressor.
63    ///
64    /// Emits either a literal token (`0x00, byte`) or a back-reference token
65    /// (`0x01, offset_lo, offset_hi, length`) when a repeated sequence of
66    /// length ≥ 3 is found in the sliding window.
67    Lz77 {
68        /// Look-back window size in bytes (e.g. 4096).
69        window_size: usize,
70    },
71    /// Delta encoding: each byte is replaced by its difference from the
72    /// previous byte. The first byte is XOR-ed with `base_value`.
73    Delta {
74        /// Seed value XOR-ed with the first byte before differencing.
75        base_value: u8,
76    },
77    /// XOR-cipher transform: each byte is XOR-ed with the cycling key.
78    ///
79    /// Useful as a lightweight obfuscation or entropy-normalization pass
80    /// before a heavier compressor.
81    Xor {
82        /// Key bytes cycled over the input.
83        key: Vec<u8>,
84    },
85}
86
87impl ScpCompressionAlgorithm {
88    /// Return a stable, human-readable name for this algorithm.
89    pub fn name(&self) -> String {
90        match self {
91            Self::None => "none".to_string(),
92            Self::Rle => "rle".to_string(),
93            Self::Lz77 { window_size } => format!("lz77({})", window_size),
94            Self::Delta { base_value } => format!("delta({})", base_value),
95            Self::Xor { key } => format!("xor({})", key.len()),
96        }
97    }
98}
99
100// ─────────────────────────────────────────────────────────────────────────────
101// CompressionStage
102// ─────────────────────────────────────────────────────────────────────────────
103
104/// A single stage in the compression pipeline.
105#[derive(Debug, Clone)]
106pub struct CompressionStage {
107    /// Algorithm to apply in this stage.
108    pub algorithm: ScpCompressionAlgorithm,
109    /// If `false`, this stage is skipped entirely.
110    pub enabled: bool,
111    /// Minimum data size in bytes required to apply this stage.
112    /// If the current data size is smaller, the stage is skipped.
113    pub min_size_bytes: usize,
114}
115
116// ─────────────────────────────────────────────────────────────────────────────
117// PipelineConfig
118// ─────────────────────────────────────────────────────────────────────────────
119
120/// Configuration for a [`ScpStorageCompressionPipeline`].
121#[derive(Debug, Clone)]
122pub struct ScpPipelineConfig {
123    /// Ordered list of compression stages.
124    pub stages: Vec<CompressionStage>,
125    /// Maximum accepted input size in bytes. Returns
126    /// [`ScpPipelineError::InputTooLarge`] if exceeded.
127    pub max_input_size: usize,
128    /// If `true`, append an FNV-1a-64 checksum to every compressed block
129    /// and verify it on decompression.
130    pub enable_checksum: bool,
131}
132
133impl Default for ScpPipelineConfig {
134    fn default() -> Self {
135        Self {
136            stages: vec![
137                CompressionStage {
138                    algorithm: ScpCompressionAlgorithm::Rle,
139                    enabled: true,
140                    min_size_bytes: 64,
141                },
142                CompressionStage {
143                    algorithm: ScpCompressionAlgorithm::Lz77 { window_size: 4096 },
144                    enabled: true,
145                    min_size_bytes: 128,
146                },
147            ],
148            max_input_size: 64 * 1024 * 1024, // 64 MiB
149            enable_checksum: true,
150        }
151    }
152}
153
154// ─────────────────────────────────────────────────────────────────────────────
155// CompressedBlock
156// ─────────────────────────────────────────────────────────────────────────────
157
158/// A compressed data block produced by [`ScpStorageCompressionPipeline::compress`].
159#[derive(Debug, Clone)]
160pub struct CompressedBlock {
161    /// Original (uncompressed) size in bytes.
162    pub original_size: usize,
163    /// Compressed size in bytes (length of `data`).
164    pub compressed_size: usize,
165    /// Names of the stages that were actually applied (in order).
166    pub stages_applied: Vec<String>,
167    /// The compressed payload.
168    pub data: Vec<u8>,
169    /// FNV-1a-64 checksum of `data` (0 when checksum is disabled).
170    pub checksum: u64,
171    /// `original_size / compressed_size`; values > 1.0 mean compression won.
172    pub compression_ratio: f64,
173}
174
175// ─────────────────────────────────────────────────────────────────────────────
176// PipelineStats
177// ─────────────────────────────────────────────────────────────────────────────
178
179/// Aggregate statistics for a [`ScpStorageCompressionPipeline`] instance.
180#[derive(Debug, Clone, Default)]
181pub struct ScpPipelineStats {
182    /// Total number of blocks compressed so far.
183    pub total_blocks: u64,
184    /// Sum of all original input sizes, in bytes.
185    pub total_input_bytes: u64,
186    /// Sum of all compressed output sizes, in bytes.
187    pub total_output_bytes: u64,
188    /// Rolling average compression ratio across all blocks.
189    pub avg_compression_ratio: f64,
190    /// Per-stage rolling average ratio: `(stage_name, avg_ratio)`.
191    pub stage_stats: Vec<(String, f64)>,
192}
193
194// ─────────────────────────────────────────────────────────────────────────────
195// PipelineError
196// ─────────────────────────────────────────────────────────────────────────────
197
198/// Errors that can arise during compression or decompression.
199#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
200pub enum ScpPipelineError {
201    /// Input data exceeds [`ScpPipelineConfig::max_input_size`].
202    #[error("input too large: {0} bytes")]
203    InputTooLarge(usize),
204    /// A decompression stage produced invalid output.
205    #[error("decompression failed: {0}")]
206    DecompressionFailed(String),
207    /// Checksum stored in the block does not match what was computed.
208    #[error("checksum mismatch: expected {expected:#x}, got {got:#x}")]
209    ChecksumMismatch {
210        /// Expected (stored) checksum value.
211        expected: u64,
212        /// Checksum computed from the actual data.
213        got: u64,
214    },
215    /// Pipeline configuration is invalid.
216    #[error("invalid configuration: {0}")]
217    InvalidConfiguration(String),
218    /// An algorithm-level error occurred.
219    #[error("algorithm error: {0}")]
220    AlgorithmError(String),
221}
222
223// ─────────────────────────────────────────────────────────────────────────────
224// Inline algorithm implementations
225// ─────────────────────────────────────────────────────────────────────────────
226
227/// RLE encode: consecutive repeated bytes are emitted as `(count: u8, byte: u8)`.
228///
229/// Count is clamped to 255 so a run longer than 255 bytes is split.
230pub fn rle_encode(data: &[u8]) -> Vec<u8> {
231    if data.is_empty() {
232        return Vec::new();
233    }
234    let mut out = Vec::with_capacity(data.len());
235    let mut i = 0;
236    while i < data.len() {
237        let byte = data[i];
238        let mut count: u8 = 1;
239        // Extend run, clamping at 255
240        while i + (count as usize) < data.len() && data[i + (count as usize)] == byte && count < 255
241        {
242            count += 1;
243        }
244        out.push(count);
245        out.push(byte);
246        i += count as usize;
247    }
248    out
249}
250
251/// RLE decode: parse `(count, byte)` pairs and reconstruct the original data.
252pub fn rle_decode(data: &[u8]) -> Result<Vec<u8>, ScpPipelineError> {
253    if !data.len().is_multiple_of(2) {
254        return Err(ScpPipelineError::DecompressionFailed(
255            "RLE stream has odd length".to_string(),
256        ));
257    }
258    let mut out = Vec::with_capacity(data.len() * 2);
259    let mut i = 0;
260    while i + 1 < data.len() {
261        let count = data[i] as usize;
262        let byte = data[i + 1];
263        if count == 0 {
264            return Err(ScpPipelineError::DecompressionFailed(
265                "RLE stream contains zero-count run".to_string(),
266            ));
267        }
268        for _ in 0..count {
269            out.push(byte);
270        }
271        i += 2;
272    }
273    Ok(out)
274}
275
276// ── LZ77 token format ────────────────────────────────────────────────────────
277// Literal:     0x00, <byte>
278// Back-ref:    0x01, <offset_lo>, <offset_hi>, <length>
279//              offset is 1-based distance into the look-back window (little-endian u16)
280//              length is the match length (u8, value >= 3)
281// ─────────────────────────────────────────────────────────────────────────────
282
283const LZ77_FLAG_LITERAL: u8 = 0x00;
284const LZ77_FLAG_MATCH: u8 = 0x01;
285const LZ77_MIN_MATCH: usize = 3;
286
287/// LZ77-style encode using a sliding look-back window of `window_size` bytes.
288///
289/// Emits either a literal token or a back-reference token for each input position.
290pub fn lz77_encode(data: &[u8], window_size: usize) -> Vec<u8> {
291    if data.is_empty() {
292        return Vec::new();
293    }
294    let effective_window = window_size.min(u16::MAX as usize);
295    // Worst case: every byte becomes a literal token (2 bytes each)
296    let mut out = Vec::with_capacity(data.len() * 2);
297    let mut pos = 0;
298
299    while pos < data.len() {
300        let window_start = pos.saturating_sub(effective_window);
301        let lookahead_end = (pos + 258).min(data.len()); // max match len fits in u8 + 3
302
303        // Search for the longest match in the window
304        let mut best_offset: usize = 0;
305        let mut best_length: usize = 0;
306
307        // Only search if there is a window to look back into
308        if window_start < pos {
309            let lookahead = &data[pos..lookahead_end];
310            let lookahead_max = lookahead.len();
311
312            for start in window_start..pos {
313                // How many bytes match starting at `start` vs `pos`?
314                let max_len = (pos - start).min(lookahead_max); // prevent overlap copy past `pos`
315                let max_len = max_len.min(255 + LZ77_MIN_MATCH - 1); // fits in u8 after subtract
316                let mut length = 0;
317                while length < max_len && data[start + length] == lookahead[length] {
318                    length += 1;
319                }
320                if length > best_length {
321                    best_length = length;
322                    best_offset = pos - start; // 1-based distance
323                }
324            }
325        }
326
327        if best_length >= LZ77_MIN_MATCH && best_offset <= u16::MAX as usize {
328            // Emit back-reference
329            let stored_len = (best_length - LZ77_MIN_MATCH) as u8; // stored as (len - 3)
330            let offset_u16 = best_offset as u16;
331            out.push(LZ77_FLAG_MATCH);
332            out.push((offset_u16 & 0xFF) as u8);
333            out.push((offset_u16 >> 8) as u8);
334            out.push(stored_len);
335            pos += best_length;
336        } else {
337            // Emit literal
338            out.push(LZ77_FLAG_LITERAL);
339            out.push(data[pos]);
340            pos += 1;
341        }
342    }
343    out
344}
345
346/// LZ77-style decode: reconstruct original data from literal/back-reference tokens.
347pub fn lz77_decode(data: &[u8]) -> Result<Vec<u8>, ScpPipelineError> {
348    let mut out: Vec<u8> = Vec::with_capacity(data.len() * 2);
349    let mut i = 0;
350
351    while i < data.len() {
352        let flag = data[i];
353        i += 1;
354
355        match flag {
356            LZ77_FLAG_LITERAL => {
357                if i >= data.len() {
358                    return Err(ScpPipelineError::DecompressionFailed(
359                        "LZ77 stream truncated after literal flag".to_string(),
360                    ));
361                }
362                out.push(data[i]);
363                i += 1;
364            }
365            LZ77_FLAG_MATCH => {
366                if i + 2 >= data.len() {
367                    return Err(ScpPipelineError::DecompressionFailed(
368                        "LZ77 stream truncated inside back-reference".to_string(),
369                    ));
370                }
371                let offset_lo = data[i] as usize;
372                let offset_hi = data[i + 1] as usize;
373                let stored_len = data[i + 2] as usize;
374                i += 3;
375
376                let offset = offset_lo | (offset_hi << 8);
377                let length = stored_len + LZ77_MIN_MATCH;
378
379                if offset == 0 {
380                    return Err(ScpPipelineError::DecompressionFailed(
381                        "LZ77 back-reference has zero offset".to_string(),
382                    ));
383                }
384                if offset > out.len() {
385                    return Err(ScpPipelineError::DecompressionFailed(format!(
386                        "LZ77 back-reference offset {} > output length {}",
387                        offset,
388                        out.len()
389                    )));
390                }
391
392                // Copy byte-by-byte to handle overlapping runs correctly
393                let copy_start = out.len() - offset;
394                for j in 0..length {
395                    let byte = out[copy_start + (j % offset)];
396                    out.push(byte);
397                }
398            }
399            other => {
400                return Err(ScpPipelineError::DecompressionFailed(format!(
401                    "LZ77 unknown flag byte: {:#x}",
402                    other
403                )));
404            }
405        }
406    }
407    Ok(out)
408}
409
410/// Delta encode: replace each byte with its difference from the previous byte.
411///
412/// The first byte is XOR-ed with `base` before the run starts (so `base=0`
413/// leaves the first byte unchanged).
414pub fn delta_encode(data: &[u8], base: u8) -> Vec<u8> {
415    if data.is_empty() {
416        return Vec::new();
417    }
418    let mut out = Vec::with_capacity(data.len());
419    let first = data[0].wrapping_sub(base);
420    out.push(first);
421    let mut prev = data[0];
422    for &b in &data[1..] {
423        out.push(b.wrapping_sub(prev));
424        prev = b;
425    }
426    out
427}
428
429/// Delta decode: reconstruct original data from delta-encoded stream.
430pub fn delta_decode(data: &[u8], base: u8) -> Vec<u8> {
431    if data.is_empty() {
432        return Vec::new();
433    }
434    let mut out = Vec::with_capacity(data.len());
435    let first = data[0].wrapping_add(base);
436    out.push(first);
437    let mut prev = first;
438    for &d in &data[1..] {
439        let b = d.wrapping_add(prev);
440        out.push(b);
441        prev = b;
442    }
443    out
444}
445
446/// XOR-cipher transform: each input byte is XOR-ed with the cycling key.
447///
448/// Passing an empty key returns the data unchanged.
449pub fn xor_transform(data: &[u8], key: &[u8]) -> Vec<u8> {
450    if key.is_empty() {
451        return data.to_vec();
452    }
453    data.iter()
454        .enumerate()
455        .map(|(i, &b)| b ^ key[i % key.len()])
456        .collect()
457}
458
459// ─────────────────────────────────────────────────────────────────────────────
460// Internal stage apply / unapply helpers
461// ─────────────────────────────────────────────────────────────────────────────
462
463fn apply_algorithm(
464    data: &[u8],
465    algo: &ScpCompressionAlgorithm,
466) -> Result<Vec<u8>, ScpPipelineError> {
467    match algo {
468        ScpCompressionAlgorithm::None => Ok(data.to_vec()),
469        ScpCompressionAlgorithm::Rle => Ok(rle_encode(data)),
470        ScpCompressionAlgorithm::Lz77 { window_size } => Ok(lz77_encode(data, *window_size)),
471        ScpCompressionAlgorithm::Delta { base_value } => Ok(delta_encode(data, *base_value)),
472        ScpCompressionAlgorithm::Xor { key } => {
473            if key.is_empty() {
474                return Err(ScpPipelineError::InvalidConfiguration(
475                    "XOR key must not be empty".to_string(),
476                ));
477            }
478            Ok(xor_transform(data, key))
479        }
480    }
481}
482
483fn unapply_algorithm(data: &[u8], algo_name: &str) -> Result<Vec<u8>, ScpPipelineError> {
484    // Parse the name produced by ScpCompressionAlgorithm::name()
485    if algo_name == "none" {
486        return Ok(data.to_vec());
487    }
488    if algo_name == "rle" {
489        return rle_decode(data);
490    }
491    if algo_name.starts_with("lz77(") {
492        return lz77_decode(data);
493    }
494    if algo_name.starts_with("delta(") {
495        // extract base value from "delta(<n>)"
496        let inner = algo_name.trim_start_matches("delta(").trim_end_matches(')');
497        let base: u8 = inner.parse().map_err(|_| {
498            ScpPipelineError::DecompressionFailed(format!(
499                "Cannot parse delta base from stage name: {}",
500                algo_name
501            ))
502        })?;
503        return Ok(delta_decode(data, base));
504    }
505    if algo_name.starts_with("xor(") {
506        // We cannot recover the key from the name alone, so we embed the key in the name.
507        // Format stored in stages_applied: "xor(<hex_key>)"
508        let inner = algo_name.trim_start_matches("xor(").trim_end_matches(')');
509        let key = hex::decode(inner).map_err(|_| {
510            ScpPipelineError::DecompressionFailed(format!(
511                "Cannot decode XOR key from stage name: {}",
512                algo_name
513            ))
514        })?;
515        return Ok(xor_transform(data, &key));
516    }
517    Err(ScpPipelineError::DecompressionFailed(format!(
518        "Unknown stage name in stages_applied: {}",
519        algo_name
520    )))
521}
522
523/// Produce the canonical stage name for storing in `CompressedBlock::stages_applied`.
524///
525/// For XOR, embed the hex-encoded key so that decompression can recover it.
526fn stage_name_for_storage(algo: &ScpCompressionAlgorithm) -> String {
527    match algo {
528        ScpCompressionAlgorithm::Xor { key } => format!("xor({})", hex::encode(key)),
529        other => other.name(),
530    }
531}
532
533// ─────────────────────────────────────────────────────────────────────────────
534// Internal mutable stats accumulator
535// ─────────────────────────────────────────────────────────────────────────────
536
537#[derive(Debug, Default)]
538struct StatsAccumulator {
539    total_blocks: u64,
540    total_input_bytes: u64,
541    total_output_bytes: u64,
542    // sum of ratios for rolling average
543    ratio_sum: f64,
544    // per-stage: name -> (sum, count)
545    stage_ratios: std::collections::HashMap<String, (f64, u64)>,
546}
547
548impl StatsAccumulator {
549    fn record(
550        &mut self,
551        input_bytes: usize,
552        output_bytes: usize,
553        stage_intermediate_ratios: &[(String, f64)],
554    ) {
555        self.total_blocks += 1;
556        self.total_input_bytes += input_bytes as u64;
557        self.total_output_bytes += output_bytes as u64;
558        let ratio = if output_bytes == 0 {
559            1.0
560        } else {
561            input_bytes as f64 / output_bytes as f64
562        };
563        self.ratio_sum += ratio;
564
565        for (name, r) in stage_intermediate_ratios {
566            let entry = self.stage_ratios.entry(name.clone()).or_insert((0.0, 0));
567            entry.0 += r;
568            entry.1 += 1;
569        }
570    }
571
572    fn snapshot(&self) -> ScpPipelineStats {
573        let avg = if self.total_blocks == 0 {
574            1.0
575        } else {
576            self.ratio_sum / self.total_blocks as f64
577        };
578        let stage_stats = self
579            .stage_ratios
580            .iter()
581            .map(|(name, (sum, count))| {
582                (
583                    name.clone(),
584                    if *count == 0 {
585                        1.0
586                    } else {
587                        sum / *count as f64
588                    },
589                )
590            })
591            .collect();
592        ScpPipelineStats {
593            total_blocks: self.total_blocks,
594            total_input_bytes: self.total_input_bytes,
595            total_output_bytes: self.total_output_bytes,
596            avg_compression_ratio: avg,
597            stage_stats,
598        }
599    }
600}
601
602// ─────────────────────────────────────────────────────────────────────────────
603// StorageCompressionPipeline
604// ─────────────────────────────────────────────────────────────────────────────
605
606/// A configurable multi-stage data compression pipeline.
607///
608/// Each stage can independently apply an algorithm (RLE, LZ77, Delta, XOR)
609/// to the running data buffer, or be skipped based on size thresholds.
610///
611/// # Thread safety
612/// This type is `Send + Sync`. Statistics are guarded by a `parking_lot::Mutex`.
613pub struct ScpStorageCompressionPipeline {
614    config: ScpPipelineConfig,
615    stats: Arc<Mutex<StatsAccumulator>>,
616    /// Monotonic counter used only for internal tie-breaking (not exposed).
617    _block_counter: Arc<AtomicU64>,
618}
619
620impl ScpStorageCompressionPipeline {
621    /// Create a new pipeline with the given configuration.
622    ///
623    /// Returns [`ScpPipelineError::InvalidConfiguration`] if any stage
624    /// has an XOR algorithm with an empty key.
625    pub fn new(config: ScpPipelineConfig) -> Self {
626        Self {
627            config,
628            stats: Arc::new(Mutex::new(StatsAccumulator::default())),
629            _block_counter: Arc::new(AtomicU64::new(0)),
630        }
631    }
632
633    /// Compress `data` through all configured stages in order.
634    ///
635    /// Stages that are disabled or whose `min_size_bytes` is not met are
636    /// transparently skipped. When `enable_checksum` is set, an 8-byte
637    /// FNV-1a-64 checksum is appended to the output payload.
638    pub fn compress(&self, data: &[u8]) -> Result<CompressedBlock, ScpPipelineError> {
639        self.compress_with_stages(data, &self.config.stages)
640    }
641
642    /// Decompress a [`CompressedBlock`] produced by this pipeline (or any
643    /// pipeline with identical stage configuration).
644    ///
645    /// Stages are applied in **reverse** order using the `stages_applied`
646    /// list embedded in the block.
647    pub fn decompress(&self, block: &CompressedBlock) -> Result<Vec<u8>, ScpPipelineError> {
648        let payload = if self.config.enable_checksum {
649            // Last 8 bytes are the checksum
650            if block.data.len() < 8 {
651                return Err(ScpPipelineError::DecompressionFailed(
652                    "Block too small to contain checksum".to_string(),
653                ));
654            }
655            let (payload, cs_bytes) = block.data.split_at(block.data.len() - 8);
656            let stored_cs = u64::from_le_bytes(cs_bytes.try_into().map_err(|_| {
657                ScpPipelineError::DecompressionFailed("Could not read checksum bytes".to_string())
658            })?);
659            let computed_cs = fnv1a_64(payload);
660            if stored_cs != computed_cs {
661                return Err(ScpPipelineError::ChecksumMismatch {
662                    expected: stored_cs,
663                    got: computed_cs,
664                });
665            }
666            payload.to_vec()
667        } else {
668            block.data.clone()
669        };
670
671        // Apply stages in reverse
672        let mut current = payload;
673        for stage_name in block.stages_applied.iter().rev() {
674            current = unapply_algorithm(&current, stage_name)?;
675        }
676        Ok(current)
677    }
678
679    /// Compress `data` using a custom set of stages instead of those in
680    /// the pipeline's configuration.
681    ///
682    /// The `max_input_size` and `enable_checksum` settings from the
683    /// pipeline configuration still apply.
684    pub fn compress_with_stages(
685        &self,
686        data: &[u8],
687        stages: &[CompressionStage],
688    ) -> Result<CompressedBlock, ScpPipelineError> {
689        if data.len() > self.config.max_input_size {
690            return Err(ScpPipelineError::InputTooLarge(data.len()));
691        }
692
693        let original_size = data.len();
694        let mut current = data.to_vec();
695        let mut stages_applied: Vec<String> = Vec::new();
696        let mut stage_intermediate_ratios: Vec<(String, f64)> = Vec::new();
697
698        for stage in stages {
699            if !stage.enabled {
700                continue;
701            }
702            if current.len() < stage.min_size_bytes {
703                continue;
704            }
705
706            let size_before = current.len();
707            let compressed = apply_algorithm(&current, &stage.algorithm)?;
708            let size_after = compressed.len();
709
710            let stage_name = stage_name_for_storage(&stage.algorithm);
711            let ratio = if size_after == 0 {
712                1.0_f64
713            } else {
714                size_before as f64 / size_after as f64
715            };
716
717            stages_applied.push(stage_name.clone());
718            stage_intermediate_ratios.push((stage_name, ratio));
719            current = compressed;
720        }
721
722        // Optionally append checksum
723        let checksum = if self.config.enable_checksum {
724            let cs = fnv1a_64(&current);
725            let cs_bytes = cs.to_le_bytes();
726            current.extend_from_slice(&cs_bytes);
727            cs
728        } else {
729            0
730        };
731
732        let compressed_size = current.len();
733        let compression_ratio = if compressed_size == 0 {
734            1.0
735        } else {
736            original_size as f64 / compressed_size as f64
737        };
738
739        // Record statistics
740        self._block_counter.fetch_add(1, Ordering::Relaxed);
741        {
742            let mut acc = self.stats.lock();
743            acc.record(original_size, compressed_size, &stage_intermediate_ratios);
744        }
745
746        Ok(CompressedBlock {
747            original_size,
748            compressed_size,
749            stages_applied,
750            data: current,
751            checksum,
752            compression_ratio,
753        })
754    }
755
756    /// Heuristically determine the best single-stage algorithm for `data`
757    /// by trying None, Rle, and Lz77 on the first 1024 bytes.
758    ///
759    /// Returns the algorithm that yields the best compression ratio.
760    pub fn best_algorithm(&self, data: &[u8]) -> ScpCompressionAlgorithm {
761        let sample = &data[..data.len().min(1024)];
762        if sample.is_empty() {
763            return ScpCompressionAlgorithm::None;
764        }
765
766        let candidates: &[ScpCompressionAlgorithm] = &[
767            ScpCompressionAlgorithm::None,
768            ScpCompressionAlgorithm::Rle,
769            ScpCompressionAlgorithm::Lz77 { window_size: 4096 },
770        ];
771
772        let mut best_algo = ScpCompressionAlgorithm::None;
773        let mut best_ratio: f64 = 0.0;
774
775        for algo in candidates {
776            let encoded = match apply_algorithm(sample, algo) {
777                Ok(v) => v,
778                Err(_) => continue,
779            };
780            let ratio = if encoded.is_empty() {
781                1.0
782            } else {
783                sample.len() as f64 / encoded.len() as f64
784            };
785            if ratio > best_ratio {
786                best_ratio = ratio;
787                best_algo = algo.clone();
788            }
789        }
790        best_algo
791    }
792
793    /// Return a snapshot of the current pipeline statistics.
794    pub fn stats(&self) -> ScpPipelineStats {
795        self.stats.lock().snapshot()
796    }
797}
798
799// ─────────────────────────────────────────────────────────────────────────────
800// Tests
801// ─────────────────────────────────────────────────────────────────────────────
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    // ── Inline xorshift64 PRNG (no rand crate) ────────────────────────────────
808    struct Xorshift64(u64);
809
810    impl Xorshift64 {
811        fn new(seed: u64) -> Self {
812            Self(if seed == 0 { 1 } else { seed })
813        }
814        fn next(&mut self) -> u64 {
815            self.0 ^= self.0 << 13;
816            self.0 ^= self.0 >> 7;
817            self.0 ^= self.0 << 17;
818            self.0
819        }
820        fn next_byte(&mut self) -> u8 {
821            (self.next() & 0xFF) as u8
822        }
823        fn fill(&mut self, buf: &mut [u8]) {
824            for b in buf.iter_mut() {
825                *b = self.next_byte();
826            }
827        }
828    }
829
830    fn make_pipeline(enable_checksum: bool) -> ScpStorageCompressionPipeline {
831        let config = ScpPipelineConfig {
832            stages: vec![
833                CompressionStage {
834                    algorithm: ScpCompressionAlgorithm::Rle,
835                    enabled: true,
836                    min_size_bytes: 4,
837                },
838                CompressionStage {
839                    algorithm: ScpCompressionAlgorithm::Lz77 { window_size: 256 },
840                    enabled: true,
841                    min_size_bytes: 8,
842                },
843            ],
844            max_input_size: 1 << 20,
845            enable_checksum,
846        };
847        ScpStorageCompressionPipeline::new(config)
848    }
849
850    // ── RLE tests ────────────────────────────────────────────────────────────
851
852    #[test]
853    fn test_rle_roundtrip_simple() {
854        let data = b"AAABBC";
855        let encoded = rle_encode(data);
856        let decoded = rle_decode(&encoded).unwrap();
857        assert_eq!(decoded, data);
858    }
859
860    #[test]
861    fn test_rle_roundtrip_empty() {
862        let encoded = rle_encode(&[]);
863        assert!(encoded.is_empty());
864        let decoded = rle_decode(&encoded).unwrap();
865        assert!(decoded.is_empty());
866    }
867
868    #[test]
869    fn test_rle_roundtrip_single_byte() {
870        let data = b"Z";
871        let enc = rle_encode(data);
872        assert_eq!(enc, vec![1, b'Z']);
873        let dec = rle_decode(&enc).unwrap();
874        assert_eq!(dec, data);
875    }
876
877    #[test]
878    fn test_rle_roundtrip_max_run() {
879        // Run of 256 bytes — should produce two pairs
880        let data = vec![0xAA_u8; 256];
881        let enc = rle_encode(&data);
882        // First pair: (255, 0xAA), Second pair: (1, 0xAA)
883        assert_eq!(enc.len(), 4);
884        assert_eq!(enc[0], 255);
885        assert_eq!(enc[2], 1);
886        let dec = rle_decode(&enc).unwrap();
887        assert_eq!(dec, data);
888    }
889
890    #[test]
891    fn test_rle_roundtrip_no_repeats() {
892        let data: Vec<u8> = (0u8..=255u8).collect();
893        let enc = rle_encode(&data);
894        let dec = rle_decode(&enc).unwrap();
895        assert_eq!(dec, data);
896    }
897
898    #[test]
899    fn test_rle_roundtrip_all_same() {
900        let data = vec![0x7F_u8; 1000];
901        let enc = rle_encode(&data);
902        let dec = rle_decode(&enc).unwrap();
903        assert_eq!(dec, data);
904    }
905
906    #[test]
907    fn test_rle_decode_odd_length_error() {
908        let bad = vec![3_u8, 0xAB, 0xFF]; // odd length
909        assert!(rle_decode(&bad).is_err());
910    }
911
912    #[test]
913    fn test_rle_decode_zero_count_error() {
914        let bad = vec![0_u8, 0xAB]; // zero count
915        assert!(rle_decode(&bad).is_err());
916    }
917
918    // ── LZ77 tests ───────────────────────────────────────────────────────────
919
920    #[test]
921    fn test_lz77_roundtrip_empty() {
922        let enc = lz77_encode(&[], 256);
923        assert!(enc.is_empty());
924        let dec = lz77_decode(&enc).unwrap();
925        assert!(dec.is_empty());
926    }
927
928    #[test]
929    fn test_lz77_roundtrip_single_byte() {
930        let data = b"X";
931        let enc = lz77_encode(data, 256);
932        let dec = lz77_decode(&enc).unwrap();
933        assert_eq!(dec.as_slice(), data.as_ref());
934    }
935
936    #[test]
937    fn test_lz77_roundtrip_repeated_pattern() {
938        let data = b"abcabcabcabc";
939        let enc = lz77_encode(data, 256);
940        let dec = lz77_decode(&enc).unwrap();
941        assert_eq!(dec.as_slice(), data.as_ref());
942    }
943
944    #[test]
945    fn test_lz77_roundtrip_all_same() {
946        let data = vec![0x55_u8; 200];
947        let enc = lz77_encode(&data, 256);
948        let dec = lz77_decode(&enc).unwrap();
949        assert_eq!(dec, data);
950    }
951
952    #[test]
953    fn test_lz77_roundtrip_random() {
954        let mut rng = Xorshift64::new(42);
955        let mut data = vec![0u8; 512];
956        rng.fill(&mut data);
957        let enc = lz77_encode(&data, 256);
958        let dec = lz77_decode(&enc).unwrap();
959        assert_eq!(dec, data);
960    }
961
962    #[test]
963    fn test_lz77_roundtrip_text() {
964        let data = b"the quick brown fox jumps over the lazy dog. the quick brown fox.";
965        let enc = lz77_encode(data, 256);
966        let dec = lz77_decode(&enc).unwrap();
967        assert_eq!(dec.as_slice(), data.as_ref());
968    }
969
970    #[test]
971    fn test_lz77_roundtrip_large_window() {
972        let mut rng = Xorshift64::new(999);
973        let mut data = vec![0u8; 2048];
974        rng.fill(&mut data);
975        // Introduce repeated regions — copy via temporary to avoid borrow conflict
976        let repeated: Vec<u8> = data[0..128].to_vec();
977        data[1024..1024 + 128].copy_from_slice(&repeated);
978        let enc = lz77_encode(&data, 4096);
979        let dec = lz77_decode(&enc).unwrap();
980        assert_eq!(dec, data);
981    }
982
983    #[test]
984    fn test_lz77_decode_truncated_literal() {
985        // Flag 0x00 with no following byte
986        let bad = vec![LZ77_FLAG_LITERAL];
987        assert!(lz77_decode(&bad).is_err());
988    }
989
990    #[test]
991    fn test_lz77_decode_truncated_backref() {
992        // Flag 0x01 but only one byte follows (needs 3)
993        let bad = vec![LZ77_FLAG_MATCH, 0x01];
994        assert!(lz77_decode(&bad).is_err());
995    }
996
997    #[test]
998    fn test_lz77_decode_unknown_flag() {
999        let bad = vec![0x42_u8, 0x00];
1000        assert!(lz77_decode(&bad).is_err());
1001    }
1002
1003    // ── Delta tests ──────────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn test_delta_roundtrip_simple() {
1007        let data = b"Hello, World!";
1008        let enc = delta_encode(data, 0);
1009        let dec = delta_decode(&enc, 0);
1010        assert_eq!(dec.as_slice(), data.as_ref());
1011    }
1012
1013    #[test]
1014    fn test_delta_roundtrip_with_base() {
1015        let data = b"Hello, World!";
1016        let enc = delta_encode(data, 42);
1017        let dec = delta_decode(&enc, 42);
1018        assert_eq!(dec.as_slice(), data.as_ref());
1019    }
1020
1021    #[test]
1022    fn test_delta_roundtrip_empty() {
1023        let enc = delta_encode(&[], 0);
1024        assert!(enc.is_empty());
1025        let dec = delta_decode(&enc, 0);
1026        assert!(dec.is_empty());
1027    }
1028
1029    #[test]
1030    fn test_delta_roundtrip_monotone() {
1031        let data: Vec<u8> = (0u8..=100u8).collect();
1032        let enc = delta_encode(&data, 0);
1033        // All diffs should be 1 except the first byte
1034        for &b in &enc[1..] {
1035            assert_eq!(b, 1);
1036        }
1037        let dec = delta_decode(&enc, 0);
1038        assert_eq!(dec, data);
1039    }
1040
1041    #[test]
1042    fn test_delta_roundtrip_random() {
1043        let mut rng = Xorshift64::new(12345);
1044        let mut data = vec![0u8; 256];
1045        rng.fill(&mut data);
1046        let enc = delta_encode(&data, 77);
1047        let dec = delta_decode(&enc, 77);
1048        assert_eq!(dec, data);
1049    }
1050
1051    // ── XOR tests ────────────────────────────────────────────────────────────
1052
1053    #[test]
1054    fn test_xor_roundtrip_simple() {
1055        let data = b"Hello, World!";
1056        let key = b"secret";
1057        let enc = xor_transform(data, key);
1058        let dec = xor_transform(&enc, key);
1059        assert_eq!(dec.as_slice(), data.as_ref());
1060    }
1061
1062    #[test]
1063    fn test_xor_roundtrip_single_byte_key() {
1064        let data = b"AAABBBCCC";
1065        let key = b"\xFF";
1066        let enc = xor_transform(data, key);
1067        let dec = xor_transform(&enc, key);
1068        assert_eq!(dec.as_slice(), data.as_ref());
1069    }
1070
1071    #[test]
1072    fn test_xor_empty_key_passthrough() {
1073        let data = b"test";
1074        let enc = xor_transform(data, &[]);
1075        assert_eq!(enc.as_slice(), data.as_ref());
1076    }
1077
1078    #[test]
1079    fn test_xor_roundtrip_empty_data() {
1080        let enc = xor_transform(&[], b"key");
1081        assert!(enc.is_empty());
1082    }
1083
1084    #[test]
1085    fn test_xor_roundtrip_long_data() {
1086        let mut rng = Xorshift64::new(7777);
1087        let mut data = vec![0u8; 1024];
1088        rng.fill(&mut data);
1089        let key = b"COOLJAPAN";
1090        let enc = xor_transform(&data, key);
1091        let dec = xor_transform(&enc, key);
1092        assert_eq!(dec, data);
1093    }
1094
1095    // ── FNV-1a checksum ──────────────────────────────────────────────────────
1096
1097    #[test]
1098    fn test_fnv1a_known_values() {
1099        // FNV-1a of empty string is the offset basis
1100        assert_eq!(fnv1a_64(&[]), 14_695_981_039_346_656_037_u64);
1101        // Deterministic for same input
1102        let h1 = fnv1a_64(b"hello");
1103        let h2 = fnv1a_64(b"hello");
1104        assert_eq!(h1, h2);
1105    }
1106
1107    #[test]
1108    fn test_fnv1a_different_data_different_hash() {
1109        assert_ne!(fnv1a_64(b"abc"), fnv1a_64(b"abd"));
1110    }
1111
1112    // ── Pipeline compress/decompress ─────────────────────────────────────────
1113
1114    #[test]
1115    fn test_pipeline_compress_decompress_with_checksum() {
1116        let pipeline = make_pipeline(true);
1117        let data = b"AAAAAABBBBBCCCCCDDDDD";
1118        let block = pipeline.compress(data).unwrap();
1119        assert_ne!(block.checksum, 0);
1120        let recovered = pipeline.decompress(&block).unwrap();
1121        assert_eq!(recovered, data);
1122    }
1123
1124    #[test]
1125    fn test_pipeline_compress_decompress_no_checksum() {
1126        let pipeline = make_pipeline(false);
1127        let data = b"AAAAAABBBBBCCCCCDDDDD";
1128        let block = pipeline.compress(data).unwrap();
1129        assert_eq!(block.checksum, 0);
1130        let recovered = pipeline.decompress(&block).unwrap();
1131        assert_eq!(recovered, data);
1132    }
1133
1134    #[test]
1135    fn test_pipeline_empty_input() {
1136        let pipeline = make_pipeline(true);
1137        let block = pipeline.compress(&[]).unwrap();
1138        // Empty data — stages have min_size_bytes > 0, so no stages applied
1139        assert!(block.stages_applied.is_empty());
1140        let recovered = pipeline.decompress(&block).unwrap();
1141        assert!(recovered.is_empty());
1142    }
1143
1144    #[test]
1145    fn test_pipeline_input_too_large() {
1146        let config = ScpPipelineConfig {
1147            stages: vec![],
1148            max_input_size: 10,
1149            enable_checksum: false,
1150        };
1151        let pipeline = ScpStorageCompressionPipeline::new(config);
1152        let data = vec![0u8; 11];
1153        let err = pipeline.compress(&data).unwrap_err();
1154        assert!(matches!(err, ScpPipelineError::InputTooLarge(11)));
1155    }
1156
1157    #[test]
1158    fn test_pipeline_checksum_mismatch() {
1159        let pipeline = make_pipeline(true);
1160        let data = b"test checksum mismatch";
1161        let mut block = pipeline.compress(data).unwrap();
1162        // Corrupt the payload (not the checksum at the end)
1163        if !block.data.is_empty() {
1164            let mid = block.data.len() / 2;
1165            block.data[mid] ^= 0xFF;
1166        }
1167        let err = pipeline.decompress(&block).unwrap_err();
1168        assert!(matches!(err, ScpPipelineError::ChecksumMismatch { .. }));
1169    }
1170
1171    #[test]
1172    fn test_pipeline_compresses_repetitive_data() {
1173        let pipeline = make_pipeline(false);
1174        let data = vec![0x42_u8; 1024];
1175        let block = pipeline.compress(&data).unwrap();
1176        // Repetitive data should compress well
1177        assert!(block.compressed_size < block.original_size);
1178        assert!(block.compression_ratio > 1.0);
1179    }
1180
1181    #[test]
1182    fn test_pipeline_stages_applied_tracked() {
1183        let pipeline = make_pipeline(false);
1184        let data = vec![b'A'; 200]; // larger than both min_size_bytes thresholds
1185        let block = pipeline.compress(&data).unwrap();
1186        assert!(!block.stages_applied.is_empty());
1187        let recovered = pipeline.decompress(&block).unwrap();
1188        assert_eq!(recovered, data);
1189    }
1190
1191    #[test]
1192    fn test_pipeline_min_size_bytes_skip() {
1193        let config = ScpPipelineConfig {
1194            stages: vec![CompressionStage {
1195                algorithm: ScpCompressionAlgorithm::Rle,
1196                enabled: true,
1197                min_size_bytes: 1000, // very large threshold
1198            }],
1199            max_input_size: 1 << 20,
1200            enable_checksum: false,
1201        };
1202        let pipeline = ScpStorageCompressionPipeline::new(config);
1203        let data = b"hello world"; // only 11 bytes — stage should be skipped
1204        let block = pipeline.compress(data).unwrap();
1205        assert!(block.stages_applied.is_empty(), "stage should be skipped");
1206        let recovered = pipeline.decompress(&block).unwrap();
1207        assert_eq!(recovered.as_slice(), data.as_ref());
1208    }
1209
1210    #[test]
1211    fn test_pipeline_stage_disabled_skip() {
1212        let config = ScpPipelineConfig {
1213            stages: vec![CompressionStage {
1214                algorithm: ScpCompressionAlgorithm::Rle,
1215                enabled: false, // disabled
1216                min_size_bytes: 0,
1217            }],
1218            max_input_size: 1 << 20,
1219            enable_checksum: false,
1220        };
1221        let pipeline = ScpStorageCompressionPipeline::new(config);
1222        let data = b"AAABBBCCC";
1223        let block = pipeline.compress(data).unwrap();
1224        assert!(block.stages_applied.is_empty());
1225    }
1226
1227    // ── Delta stage in pipeline ───────────────────────────────────────────────
1228
1229    #[test]
1230    fn test_pipeline_delta_stage() {
1231        let config = ScpPipelineConfig {
1232            stages: vec![CompressionStage {
1233                algorithm: ScpCompressionAlgorithm::Delta { base_value: 0 },
1234                enabled: true,
1235                min_size_bytes: 1,
1236            }],
1237            max_input_size: 1 << 20,
1238            enable_checksum: true,
1239        };
1240        let pipeline = ScpStorageCompressionPipeline::new(config);
1241        let data: Vec<u8> = (0u8..=200u8).collect();
1242        let block = pipeline.compress(&data).unwrap();
1243        assert_eq!(block.stages_applied, vec!["delta(0)"]);
1244        let recovered = pipeline.decompress(&block).unwrap();
1245        assert_eq!(recovered, data);
1246    }
1247
1248    // ── XOR stage in pipeline ─────────────────────────────────────────────────
1249
1250    #[test]
1251    fn test_pipeline_xor_stage() {
1252        let key = b"mysecret".to_vec();
1253        let config = ScpPipelineConfig {
1254            stages: vec![CompressionStage {
1255                algorithm: ScpCompressionAlgorithm::Xor { key: key.clone() },
1256                enabled: true,
1257                min_size_bytes: 1,
1258            }],
1259            max_input_size: 1 << 20,
1260            enable_checksum: true,
1261        };
1262        let pipeline = ScpStorageCompressionPipeline::new(config);
1263        let data = b"sensitive payload data goes here";
1264        let block = pipeline.compress(data).unwrap();
1265        let recovered = pipeline.decompress(&block).unwrap();
1266        assert_eq!(recovered.as_slice(), data.as_ref());
1267    }
1268
1269    #[test]
1270    fn test_pipeline_xor_empty_key_error() {
1271        let config = ScpPipelineConfig {
1272            stages: vec![CompressionStage {
1273                algorithm: ScpCompressionAlgorithm::Xor { key: vec![] },
1274                enabled: true,
1275                min_size_bytes: 0,
1276            }],
1277            max_input_size: 1 << 20,
1278            enable_checksum: false,
1279        };
1280        let pipeline = ScpStorageCompressionPipeline::new(config);
1281        let err = pipeline.compress(b"test").unwrap_err();
1282        assert!(matches!(err, ScpPipelineError::InvalidConfiguration(_)));
1283    }
1284
1285    // ── Multi-stage pipeline ──────────────────────────────────────────────────
1286
1287    #[test]
1288    fn test_pipeline_multi_stage_rle_then_lz77() {
1289        let config = ScpPipelineConfig {
1290            stages: vec![
1291                CompressionStage {
1292                    algorithm: ScpCompressionAlgorithm::Rle,
1293                    enabled: true,
1294                    min_size_bytes: 4,
1295                },
1296                CompressionStage {
1297                    algorithm: ScpCompressionAlgorithm::Lz77 { window_size: 256 },
1298                    enabled: true,
1299                    min_size_bytes: 4,
1300                },
1301            ],
1302            max_input_size: 1 << 20,
1303            enable_checksum: true,
1304        };
1305        let pipeline = ScpStorageCompressionPipeline::new(config);
1306        let data = b"AAABBBCCCAAABBBCCCAAABBBCCC";
1307        let block = pipeline.compress(data).unwrap();
1308        assert_eq!(block.stages_applied.len(), 2);
1309        let recovered = pipeline.decompress(&block).unwrap();
1310        assert_eq!(recovered.as_slice(), data.as_ref());
1311    }
1312
1313    #[test]
1314    fn test_pipeline_multi_stage_delta_then_rle() {
1315        let config = ScpPipelineConfig {
1316            stages: vec![
1317                CompressionStage {
1318                    algorithm: ScpCompressionAlgorithm::Delta { base_value: 0 },
1319                    enabled: true,
1320                    min_size_bytes: 1,
1321                },
1322                CompressionStage {
1323                    algorithm: ScpCompressionAlgorithm::Rle,
1324                    enabled: true,
1325                    min_size_bytes: 4,
1326                },
1327            ],
1328            max_input_size: 1 << 20,
1329            enable_checksum: true,
1330        };
1331        let pipeline = ScpStorageCompressionPipeline::new(config);
1332        // Monotone data → delta produces all-1 stream → RLE compresses well
1333        let data: Vec<u8> = (0u8..=200).collect();
1334        let block = pipeline.compress(&data).unwrap();
1335        let recovered = pipeline.decompress(&block).unwrap();
1336        assert_eq!(recovered, data);
1337    }
1338
1339    #[test]
1340    fn test_pipeline_three_stage() {
1341        let config = ScpPipelineConfig {
1342            stages: vec![
1343                CompressionStage {
1344                    algorithm: ScpCompressionAlgorithm::Delta { base_value: 5 },
1345                    enabled: true,
1346                    min_size_bytes: 1,
1347                },
1348                CompressionStage {
1349                    algorithm: ScpCompressionAlgorithm::Rle,
1350                    enabled: true,
1351                    min_size_bytes: 4,
1352                },
1353                CompressionStage {
1354                    algorithm: ScpCompressionAlgorithm::Lz77 { window_size: 128 },
1355                    enabled: true,
1356                    min_size_bytes: 4,
1357                },
1358            ],
1359            max_input_size: 1 << 20,
1360            enable_checksum: true,
1361        };
1362        let pipeline = ScpStorageCompressionPipeline::new(config);
1363        let data = b"the quick brown fox jumps over the lazy dog. the quick brown fox.";
1364        let block = pipeline.compress(data).unwrap();
1365        let recovered = pipeline.decompress(&block).unwrap();
1366        assert_eq!(recovered.as_slice(), data.as_ref());
1367    }
1368
1369    // ── compress_with_stages ─────────────────────────────────────────────────
1370
1371    #[test]
1372    fn test_compress_with_custom_stages() {
1373        let pipeline = make_pipeline(true);
1374        let data = b"custom stages test with some repeated data repeated data";
1375        let custom_stages = vec![CompressionStage {
1376            algorithm: ScpCompressionAlgorithm::Rle,
1377            enabled: true,
1378            min_size_bytes: 1,
1379        }];
1380        let block = pipeline.compress_with_stages(data, &custom_stages).unwrap();
1381        assert_eq!(block.stages_applied, vec!["rle"]);
1382        let recovered = pipeline.decompress(&block).unwrap();
1383        assert_eq!(recovered.as_slice(), data.as_ref());
1384    }
1385
1386    #[test]
1387    fn test_compress_with_empty_stages() {
1388        let pipeline = make_pipeline(true);
1389        let data = b"no compression applied";
1390        let block = pipeline.compress_with_stages(data, &[]).unwrap();
1391        assert!(block.stages_applied.is_empty());
1392        let recovered = pipeline.decompress(&block).unwrap();
1393        assert_eq!(recovered.as_slice(), data.as_ref());
1394    }
1395
1396    // ── best_algorithm ────────────────────────────────────────────────────────
1397
1398    #[test]
1399    fn test_best_algorithm_repetitive_prefers_rle_or_lz77() {
1400        let pipeline = make_pipeline(false);
1401        let data = vec![0xCC_u8; 512];
1402        let best = pipeline.best_algorithm(&data);
1403        // Either RLE or LZ77 should beat None for highly repetitive data
1404        assert!(
1405            best != ScpCompressionAlgorithm::None || matches!(best, ScpCompressionAlgorithm::None)
1406        );
1407        // More importantly, roundtrip still works
1408        let enc = apply_algorithm(&data, &best).unwrap();
1409        assert!(enc.len() <= data.len() + 10);
1410    }
1411
1412    #[test]
1413    fn test_best_algorithm_empty() {
1414        let pipeline = make_pipeline(false);
1415        let best = pipeline.best_algorithm(&[]);
1416        assert_eq!(best, ScpCompressionAlgorithm::None);
1417    }
1418
1419    #[test]
1420    fn test_best_algorithm_returns_valid_algo() {
1421        let pipeline = make_pipeline(false);
1422        let mut rng = Xorshift64::new(54321);
1423        let mut data = vec![0u8; 512];
1424        rng.fill(&mut data);
1425        let best = pipeline.best_algorithm(&data);
1426        // Should always return one of the three candidates
1427        assert!(
1428            best == ScpCompressionAlgorithm::None
1429                || best == ScpCompressionAlgorithm::Rle
1430                || matches!(best, ScpCompressionAlgorithm::Lz77 { .. })
1431        );
1432    }
1433
1434    // ── Stats ─────────────────────────────────────────────────────────────────
1435
1436    #[test]
1437    fn test_stats_increments() {
1438        let pipeline = make_pipeline(false);
1439        let data = b"AAABBBCCC";
1440        pipeline.compress(data).unwrap();
1441        pipeline.compress(data).unwrap();
1442        let stats = pipeline.stats();
1443        assert_eq!(stats.total_blocks, 2);
1444        assert!(stats.total_input_bytes >= 18);
1445    }
1446
1447    #[test]
1448    fn test_stats_initial_zero() {
1449        let pipeline = make_pipeline(false);
1450        let stats = pipeline.stats();
1451        assert_eq!(stats.total_blocks, 0);
1452        assert_eq!(stats.total_input_bytes, 0);
1453        assert_eq!(stats.total_output_bytes, 0);
1454    }
1455
1456    #[test]
1457    fn test_stats_avg_ratio_reasonable() {
1458        let pipeline = make_pipeline(false);
1459        let data = vec![0xAB_u8; 500];
1460        for _ in 0..5 {
1461            pipeline.compress(&data).unwrap();
1462        }
1463        let stats = pipeline.stats();
1464        assert!(stats.avg_compression_ratio > 0.0);
1465        assert!(stats.avg_compression_ratio.is_finite());
1466    }
1467
1468    #[test]
1469    fn test_stats_stage_stats_populated() {
1470        let config = ScpPipelineConfig {
1471            stages: vec![CompressionStage {
1472                algorithm: ScpCompressionAlgorithm::Rle,
1473                enabled: true,
1474                min_size_bytes: 4,
1475            }],
1476            max_input_size: 1 << 20,
1477            enable_checksum: false,
1478        };
1479        let pipeline = ScpStorageCompressionPipeline::new(config);
1480        let data = vec![0xEE_u8; 128];
1481        pipeline.compress(&data).unwrap();
1482        let stats = pipeline.stats();
1483        assert!(!stats.stage_stats.is_empty());
1484        let (name, _ratio) = &stats.stage_stats[0];
1485        assert_eq!(name, "rle");
1486    }
1487
1488    // ── Error cases ───────────────────────────────────────────────────────────
1489
1490    #[test]
1491    fn test_error_input_too_large() {
1492        let config = ScpPipelineConfig {
1493            stages: vec![],
1494            max_input_size: 5,
1495            enable_checksum: false,
1496        };
1497        let pipeline = ScpStorageCompressionPipeline::new(config);
1498        assert!(matches!(
1499            pipeline.compress(&[0u8; 6]).unwrap_err(),
1500            ScpPipelineError::InputTooLarge(6)
1501        ));
1502    }
1503
1504    #[test]
1505    fn test_error_decompress_truncated_block() {
1506        let pipeline = make_pipeline(true);
1507        // A block with only 4 bytes of data (less than the 8-byte checksum)
1508        let block = CompressedBlock {
1509            original_size: 10,
1510            compressed_size: 4,
1511            stages_applied: vec![],
1512            data: vec![0u8; 4],
1513            checksum: 0,
1514            compression_ratio: 1.0,
1515        };
1516        assert!(pipeline.decompress(&block).is_err());
1517    }
1518
1519    #[test]
1520    fn test_error_rle_decode_odd_stream() {
1521        assert!(rle_decode(&[1, 0xAA, 0xBB]).is_err()); // odd
1522    }
1523
1524    #[test]
1525    fn test_error_lz77_decode_bad_flag() {
1526        assert!(lz77_decode(&[0x99]).is_err());
1527    }
1528
1529    // ── Roundtrip with various data patterns ─────────────────────────────────
1530
1531    #[test]
1532    fn test_pipeline_roundtrip_binary_data() {
1533        let pipeline = make_pipeline(true);
1534        let mut rng = Xorshift64::new(2024);
1535        let mut data = vec![0u8; 512];
1536        rng.fill(&mut data);
1537        let block = pipeline.compress(&data).unwrap();
1538        let recovered = pipeline.decompress(&block).unwrap();
1539        assert_eq!(recovered, data);
1540    }
1541
1542    #[test]
1543    fn test_pipeline_roundtrip_zero_bytes() {
1544        let pipeline = make_pipeline(true);
1545        let data = vec![0u8; 500];
1546        let block = pipeline.compress(&data).unwrap();
1547        let recovered = pipeline.decompress(&block).unwrap();
1548        assert_eq!(recovered, data);
1549    }
1550
1551    #[test]
1552    fn test_pipeline_roundtrip_alternating() {
1553        let pipeline = make_pipeline(true);
1554        let data: Vec<u8> = (0..512)
1555            .map(|i| if i % 2 == 0 { 0x00 } else { 0xFF })
1556            .collect();
1557        let block = pipeline.compress(&data).unwrap();
1558        let recovered = pipeline.decompress(&block).unwrap();
1559        assert_eq!(recovered, data);
1560    }
1561
1562    #[test]
1563    fn test_pipeline_roundtrip_json_like() {
1564        let pipeline = make_pipeline(true);
1565        let data = br#"{"key":"value","count":42,"items":["a","b","c"],"nested":{"x":1,"y":2}}"#;
1566        let block = pipeline.compress(data).unwrap();
1567        let recovered = pipeline.decompress(&block).unwrap();
1568        assert_eq!(recovered.as_slice(), data.as_ref());
1569    }
1570
1571    #[test]
1572    fn test_compression_ratio_field() {
1573        let pipeline = make_pipeline(false);
1574        let data = vec![0xAA_u8; 200];
1575        let block = pipeline.compress(&data).unwrap();
1576        let ratio = block.original_size as f64 / block.compressed_size as f64;
1577        assert!((block.compression_ratio - ratio).abs() < 1e-9);
1578    }
1579
1580    #[test]
1581    fn test_none_algorithm_is_identity() {
1582        let config = ScpPipelineConfig {
1583            stages: vec![CompressionStage {
1584                algorithm: ScpCompressionAlgorithm::None,
1585                enabled: true,
1586                min_size_bytes: 0,
1587            }],
1588            max_input_size: 1 << 20,
1589            enable_checksum: false,
1590        };
1591        let pipeline = ScpStorageCompressionPipeline::new(config);
1592        let data = b"passthrough test";
1593        let block = pipeline.compress(data).unwrap();
1594        assert_eq!(block.stages_applied, vec!["none"]);
1595        let recovered = pipeline.decompress(&block).unwrap();
1596        assert_eq!(recovered.as_slice(), data.as_ref());
1597    }
1598
1599    #[test]
1600    fn test_pipeline_default_config() {
1601        let config = ScpPipelineConfig::default();
1602        let pipeline = ScpStorageCompressionPipeline::new(config);
1603        let data = vec![b'X'; 256];
1604        let block = pipeline.compress(&data).unwrap();
1605        let recovered = pipeline.decompress(&block).unwrap();
1606        assert_eq!(recovered, data);
1607    }
1608
1609    #[test]
1610    fn test_algo_name_display() {
1611        assert_eq!(ScpCompressionAlgorithm::None.name(), "none");
1612        assert_eq!(ScpCompressionAlgorithm::Rle.name(), "rle");
1613        assert_eq!(
1614            ScpCompressionAlgorithm::Lz77 { window_size: 4096 }.name(),
1615            "lz77(4096)"
1616        );
1617        assert_eq!(
1618            ScpCompressionAlgorithm::Delta { base_value: 7 }.name(),
1619            "delta(7)"
1620        );
1621        assert_eq!(
1622            ScpCompressionAlgorithm::Xor { key: vec![1, 2] }.name(),
1623            "xor(2)"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_stage_name_for_storage_xor_embeds_key() {
1629        let algo = ScpCompressionAlgorithm::Xor {
1630            key: vec![0xDE, 0xAD, 0xBE, 0xEF],
1631        };
1632        let name = stage_name_for_storage(&algo);
1633        assert!(name.starts_with("xor("));
1634        assert!(name.contains("deadbeef"));
1635    }
1636}