Skip to main content

celers_protocol/
compression.rs

1//! Compression support for message bodies
2//!
3//! This module provides compression and decompression utilities for
4//! Celery message bodies. Compression can significantly reduce message
5//! size for large payloads.
6//!
7//! `CompressionType` is the **single source of truth** for compression
8//! algorithms across the entire celers workspace. Broker crates
9//! (`celers-broker-redis`, `celers-broker-amqp`, etc.) should reference
10//! this type rather than defining their own enum variants.
11//!
12//! # Supported Algorithms
13//!
14//! - **gzip** - Standard gzip compression (requires `gzip` feature)
15//! - **zlib** - Zlib compression (requires `zlib` feature)
16//! - **zstd** - Zstandard compression (requires `zstd-compression` feature)
17//!
18//! # Example
19//!
20//! ```ignore
21//! use celers_protocol::compression::{Compressor, CompressionType};
22//!
23//! let compressor = Compressor::new(CompressionType::Gzip);
24//! let data = b"Hello, World!".repeat(100);
25//! let compressed = compressor.compress(&data).unwrap();
26//! let decompressed = compressor.decompress(&compressed).unwrap();
27//! assert_eq!(data, decompressed);
28//! ```
29
30use serde::{Deserialize, Serialize};
31use std::fmt;
32
33// ---------------------------------------------------------------------------
34// CompressionType -- the canonical enum
35// ---------------------------------------------------------------------------
36
37/// Compression algorithm type.
38///
39/// This is the **canonical** compression enum for the entire celers
40/// workspace. All broker and backend crates should use (or convert to)
41/// this type instead of maintaining their own enum.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum CompressionType {
44    /// No compression
45    #[default]
46    None,
47    /// Gzip compression
48    #[cfg(feature = "gzip")]
49    Gzip,
50    /// Zlib compression
51    #[cfg(feature = "zlib")]
52    Zlib,
53    /// Zstandard compression
54    #[cfg(feature = "zstd-compression")]
55    Zstd,
56}
57
58impl CompressionType {
59    /// Get the content encoding string for this compression type
60    #[inline]
61    pub fn as_encoding(&self) -> &'static str {
62        match self {
63            CompressionType::None => "utf-8",
64            #[cfg(feature = "gzip")]
65            CompressionType::Gzip => "gzip",
66            #[cfg(feature = "zlib")]
67            CompressionType::Zlib => "zlib",
68            #[cfg(feature = "zstd-compression")]
69            CompressionType::Zstd => "zstd",
70        }
71    }
72
73    /// Parse from content encoding string
74    pub fn from_encoding(encoding: &str) -> Option<Self> {
75        match encoding.to_lowercase().as_str() {
76            "utf-8" | "identity" | "" => Some(CompressionType::None),
77            #[cfg(feature = "gzip")]
78            "gzip" | "x-gzip" => Some(CompressionType::Gzip),
79            #[cfg(feature = "zlib")]
80            "zlib" | "deflate" => Some(CompressionType::Zlib),
81            #[cfg(feature = "zstd-compression")]
82            "zstd" | "zstandard" => Some(CompressionType::Zstd),
83            _ => None,
84        }
85    }
86
87    /// List available compression types based on enabled features
88    pub fn available() -> Vec<CompressionType> {
89        vec![
90            CompressionType::None,
91            #[cfg(feature = "gzip")]
92            CompressionType::Gzip,
93            #[cfg(feature = "zlib")]
94            CompressionType::Zlib,
95            #[cfg(feature = "zstd-compression")]
96            CompressionType::Zstd,
97        ]
98    }
99
100    /// Get a numeric identifier byte for this compression type.
101    ///
102    /// Useful for binary framing protocols that prefix compressed
103    /// payloads with an algorithm tag.
104    pub fn id(&self) -> u8 {
105        match self {
106            CompressionType::None => 0,
107            #[cfg(feature = "gzip")]
108            CompressionType::Gzip => 1,
109            #[cfg(feature = "zlib")]
110            CompressionType::Zlib => 2,
111            #[cfg(feature = "zstd-compression")]
112            CompressionType::Zstd => 3,
113        }
114    }
115
116    /// Reconstruct a `CompressionType` from an identifier byte
117    /// produced by [`CompressionType::id`].
118    pub fn from_id(id: u8) -> Option<Self> {
119        match id {
120            0 => Some(CompressionType::None),
121            #[cfg(feature = "gzip")]
122            1 => Some(CompressionType::Gzip),
123            #[cfg(feature = "zlib")]
124            2 => Some(CompressionType::Zlib),
125            #[cfg(feature = "zstd-compression")]
126            3 => Some(CompressionType::Zstd),
127            _ => None,
128        }
129    }
130
131    /// Human-readable short name (lowercase).
132    pub fn name(&self) -> &'static str {
133        match self {
134            CompressionType::None => "none",
135            #[cfg(feature = "gzip")]
136            CompressionType::Gzip => "gzip",
137            #[cfg(feature = "zlib")]
138            CompressionType::Zlib => "zlib",
139            #[cfg(feature = "zstd-compression")]
140            CompressionType::Zstd => "zstd",
141        }
142    }
143
144    /// Returns `true` when this variant represents an actual
145    /// compression algorithm (i.e. anything other than `None`).
146    pub fn is_enabled(&self) -> bool {
147        !matches!(self, CompressionType::None)
148    }
149
150    /// Parse from a short name (case-insensitive).
151    pub fn from_name(s: &str) -> Option<Self> {
152        match s.to_lowercase().as_str() {
153            "none" => Some(CompressionType::None),
154            #[cfg(feature = "gzip")]
155            "gzip" => Some(CompressionType::Gzip),
156            #[cfg(feature = "zlib")]
157            "zlib" | "deflate" => Some(CompressionType::Zlib),
158            #[cfg(feature = "zstd-compression")]
159            "zstd" | "zstandard" => Some(CompressionType::Zstd),
160            _ => None,
161        }
162    }
163}
164
165impl fmt::Display for CompressionType {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "{}", self.as_encoding())
168    }
169}
170
171impl TryFrom<&str> for CompressionType {
172    type Error = String;
173
174    fn try_from(s: &str) -> Result<Self, Self::Error> {
175        Self::from_encoding(s).ok_or_else(|| format!("Unknown compression type: {}", s))
176    }
177}
178
179// ---------------------------------------------------------------------------
180// CompressionRegistry
181// ---------------------------------------------------------------------------
182
183/// Registry that tracks which compression algorithms are available
184/// at runtime and which one is the default.
185#[derive(Debug, Clone)]
186pub struct CompressionRegistry {
187    default: CompressionType,
188    available: Vec<CompressionType>,
189}
190
191impl CompressionRegistry {
192    /// Create a new registry with all feature-enabled algorithms
193    /// and `None` as the default.
194    pub fn new() -> Self {
195        Self {
196            default: CompressionType::None,
197            available: CompressionType::available(),
198        }
199    }
200
201    /// Create a registry with a specific default algorithm.
202    ///
203    /// The available list is still populated from enabled features.
204    /// Returns an error string if `algo` is not in the available set.
205    pub fn with_default(algo: CompressionType) -> Result<Self, String> {
206        let available = CompressionType::available();
207        if !available.contains(&algo) {
208            return Err(format!(
209                "Compression type {:?} is not available (enabled features: {:?})",
210                algo, available
211            ));
212        }
213        Ok(Self {
214            default: algo,
215            available,
216        })
217    }
218
219    /// The default compression type.
220    pub fn default_type(&self) -> CompressionType {
221        self.default
222    }
223
224    /// All compression types currently available.
225    pub fn available_types(&self) -> &[CompressionType] {
226        &self.available
227    }
228
229    /// Check whether a given compression type is available.
230    pub fn is_available(&self, algo: &CompressionType) -> bool {
231        self.available.contains(algo)
232    }
233}
234
235impl Default for CompressionRegistry {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241// ---------------------------------------------------------------------------
242// CompressionStats
243// ---------------------------------------------------------------------------
244
245/// Cumulative compression statistics.
246///
247/// Tracks totals across multiple compress operations so callers
248/// can monitor compression effectiveness over time.
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct CompressionStats {
251    /// Total original (uncompressed) bytes seen.
252    pub original_bytes: u64,
253    /// Total compressed bytes produced.
254    pub compressed_bytes: u64,
255    /// Number of successful compress/decompress operations.
256    pub operations: u64,
257    /// Number of failed operations.
258    pub failures: u64,
259}
260
261impl CompressionStats {
262    /// Overall compression ratio (`compressed / original`).
263    ///
264    /// Returns `0.0` when no bytes have been recorded.
265    pub fn ratio(&self) -> f64 {
266        if self.original_bytes == 0 {
267            return 0.0;
268        }
269        self.compressed_bytes as f64 / self.original_bytes as f64
270    }
271
272    /// Record a successful compression operation.
273    pub fn record(&mut self, original: usize, compressed: usize) {
274        self.original_bytes += original as u64;
275        self.compressed_bytes += compressed as u64;
276        self.operations += 1;
277    }
278
279    /// Record a failed compression/decompression attempt.
280    pub fn record_failure(&mut self) {
281        self.failures += 1;
282    }
283
284    /// Savings percentage (`(1 - ratio) * 100`).
285    pub fn savings_percent(&self) -> f64 {
286        if self.original_bytes == 0 {
287            return 0.0;
288        }
289        (1.0 - self.ratio()) * 100.0
290    }
291}
292
293// ---------------------------------------------------------------------------
294// CompressionError
295// ---------------------------------------------------------------------------
296
297/// Compression error
298#[derive(Debug)]
299pub enum CompressionError {
300    /// Compression failed
301    Compress(String),
302    /// Decompression failed
303    Decompress(String),
304    /// Unsupported compression type
305    UnsupportedType(String),
306}
307
308impl fmt::Display for CompressionError {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match self {
311            CompressionError::Compress(msg) => write!(f, "Compression error: {}", msg),
312            CompressionError::Decompress(msg) => write!(f, "Decompression error: {}", msg),
313            CompressionError::UnsupportedType(t) => {
314                write!(f, "Unsupported compression type: {}", t)
315            }
316        }
317    }
318}
319
320impl std::error::Error for CompressionError {}
321
322/// Result type for compression operations
323pub type CompressionResult<T> = Result<T, CompressionError>;
324
325// ---------------------------------------------------------------------------
326// Compressor
327// ---------------------------------------------------------------------------
328
329/// Compressor with configurable algorithm and level
330#[derive(Debug, Clone)]
331pub struct Compressor {
332    /// Compression type
333    pub compression_type: CompressionType,
334    /// Compression level (1-9 for gzip/zlib, 1-22 for zstd)
335    pub level: u32,
336}
337
338impl Default for Compressor {
339    fn default() -> Self {
340        Self {
341            compression_type: CompressionType::None,
342            level: 6,
343        }
344    }
345}
346
347impl Compressor {
348    /// Create a new compressor with default level
349    pub fn new(compression_type: CompressionType) -> Self {
350        Self {
351            compression_type,
352            level: 6,
353        }
354    }
355
356    /// Set compression level
357    #[must_use]
358    pub fn with_level(mut self, level: u32) -> Self {
359        self.level = level;
360        self
361    }
362
363    /// Compress data
364    pub fn compress(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
365        match self.compression_type {
366            CompressionType::None => Ok(data.to_vec()),
367            #[cfg(feature = "gzip")]
368            CompressionType::Gzip => self.compress_gzip(data),
369            #[cfg(feature = "zlib")]
370            CompressionType::Zlib => self.compress_zlib(data),
371            #[cfg(feature = "zstd-compression")]
372            CompressionType::Zstd => self.compress_zstd(data),
373        }
374    }
375
376    /// Decompress data
377    pub fn decompress(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
378        match self.compression_type {
379            CompressionType::None => Ok(data.to_vec()),
380            #[cfg(feature = "gzip")]
381            CompressionType::Gzip => self.decompress_gzip(data),
382            #[cfg(feature = "zlib")]
383            CompressionType::Zlib => self.decompress_zlib(data),
384            #[cfg(feature = "zstd-compression")]
385            CompressionType::Zstd => self.decompress_zstd(data),
386        }
387    }
388
389    /// Get the content encoding string
390    pub fn content_encoding(&self) -> &'static str {
391        self.compression_type.as_encoding()
392    }
393
394    #[cfg(feature = "gzip")]
395    fn compress_gzip(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
396        let level = self.level.min(9) as u8;
397        oxiarc_deflate::gzip_compress(data, level)
398            .map_err(|e| CompressionError::Compress(e.to_string()))
399    }
400
401    #[cfg(feature = "gzip")]
402    fn decompress_gzip(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
403        oxiarc_deflate::gzip_decompress(data)
404            .map_err(|e| CompressionError::Decompress(e.to_string()))
405    }
406
407    #[cfg(feature = "zlib")]
408    fn compress_zlib(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
409        let level = self.level.min(9) as u8;
410        oxiarc_deflate::zlib_compress(data, level)
411            .map_err(|e| CompressionError::Compress(e.to_string()))
412    }
413
414    #[cfg(feature = "zlib")]
415    fn decompress_zlib(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
416        oxiarc_deflate::zlib_decompress(data)
417            .map_err(|e| CompressionError::Decompress(e.to_string()))
418    }
419
420    #[cfg(feature = "zstd-compression")]
421    fn compress_zstd(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
422        let level = self.level.min(22) as i32;
423        oxiarc_zstd::encode_all(data, level).map_err(|e| CompressionError::Compress(e.to_string()))
424    }
425
426    #[cfg(feature = "zstd-compression")]
427    fn decompress_zstd(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
428        oxiarc_zstd::decode_all(data).map_err(|e| CompressionError::Decompress(e.to_string()))
429    }
430}
431
432/// Auto-detect compression type from data header
433pub fn detect_compression(data: &[u8]) -> CompressionType {
434    if data.len() < 2 {
435        return CompressionType::None;
436    }
437
438    // Gzip magic number: 1f 8b
439    #[cfg(feature = "gzip")]
440    if data[0] == 0x1f && data[1] == 0x8b {
441        return CompressionType::Gzip;
442    }
443
444    // Zlib header: first byte is typically 0x78 (CMF byte)
445    // 0x78 0x01 = no/low compression, 0x78 0x9C = default, 0x78 0xDA = best
446    #[cfg(feature = "zlib")]
447    if data[0] == 0x78 && (data[1] == 0x01 || data[1] == 0x5E || data[1] == 0x9C || data[1] == 0xDA)
448    {
449        return CompressionType::Zlib;
450    }
451
452    // Zstd magic number: 28 b5 2f fd
453    #[cfg(feature = "zstd-compression")]
454    if data.len() >= 4 && data[0] == 0x28 && data[1] == 0xb5 && data[2] == 0x2f && data[3] == 0xfd {
455        return CompressionType::Zstd;
456    }
457
458    CompressionType::None
459}
460
461/// Decompress data with auto-detection
462pub fn auto_decompress(data: &[u8]) -> CompressionResult<Vec<u8>> {
463    let compression_type = detect_compression(data);
464    Compressor::new(compression_type).decompress(data)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_compression_type_as_encoding() {
473        assert_eq!(CompressionType::None.as_encoding(), "utf-8");
474        #[cfg(feature = "gzip")]
475        assert_eq!(CompressionType::Gzip.as_encoding(), "gzip");
476        #[cfg(feature = "zlib")]
477        assert_eq!(CompressionType::Zlib.as_encoding(), "zlib");
478        #[cfg(feature = "zstd-compression")]
479        assert_eq!(CompressionType::Zstd.as_encoding(), "zstd");
480    }
481
482    #[test]
483    fn test_compression_type_from_encoding() {
484        assert_eq!(
485            CompressionType::from_encoding("utf-8"),
486            Some(CompressionType::None)
487        );
488        assert_eq!(
489            CompressionType::from_encoding("identity"),
490            Some(CompressionType::None)
491        );
492        #[cfg(feature = "gzip")]
493        assert_eq!(
494            CompressionType::from_encoding("gzip"),
495            Some(CompressionType::Gzip)
496        );
497        #[cfg(feature = "zlib")]
498        assert_eq!(
499            CompressionType::from_encoding("zlib"),
500            Some(CompressionType::Zlib)
501        );
502        #[cfg(feature = "zstd-compression")]
503        assert_eq!(
504            CompressionType::from_encoding("zstd"),
505            Some(CompressionType::Zstd)
506        );
507        assert_eq!(CompressionType::from_encoding("unknown"), None);
508    }
509
510    #[test]
511    fn test_compression_type_default() {
512        assert_eq!(CompressionType::default(), CompressionType::None);
513    }
514
515    #[test]
516    fn test_compression_type_display() {
517        assert_eq!(CompressionType::None.to_string(), "utf-8");
518    }
519
520    #[test]
521    fn test_compression_type_id_roundtrip() {
522        assert_eq!(
523            CompressionType::from_id(CompressionType::None.id()),
524            Some(CompressionType::None)
525        );
526        #[cfg(feature = "gzip")]
527        assert_eq!(
528            CompressionType::from_id(CompressionType::Gzip.id()),
529            Some(CompressionType::Gzip)
530        );
531        #[cfg(feature = "zlib")]
532        assert_eq!(
533            CompressionType::from_id(CompressionType::Zlib.id()),
534            Some(CompressionType::Zlib)
535        );
536        #[cfg(feature = "zstd-compression")]
537        assert_eq!(
538            CompressionType::from_id(CompressionType::Zstd.id()),
539            Some(CompressionType::Zstd)
540        );
541        assert_eq!(CompressionType::from_id(255), None);
542    }
543
544    #[test]
545    fn test_compression_type_name() {
546        assert_eq!(CompressionType::None.name(), "none");
547        #[cfg(feature = "gzip")]
548        assert_eq!(CompressionType::Gzip.name(), "gzip");
549        #[cfg(feature = "zlib")]
550        assert_eq!(CompressionType::Zlib.name(), "zlib");
551        #[cfg(feature = "zstd-compression")]
552        assert_eq!(CompressionType::Zstd.name(), "zstd");
553    }
554
555    #[test]
556    fn test_compression_type_is_enabled() {
557        assert!(!CompressionType::None.is_enabled());
558        #[cfg(feature = "gzip")]
559        assert!(CompressionType::Gzip.is_enabled());
560        #[cfg(feature = "zlib")]
561        assert!(CompressionType::Zlib.is_enabled());
562        #[cfg(feature = "zstd-compression")]
563        assert!(CompressionType::Zstd.is_enabled());
564    }
565
566    #[test]
567    fn test_compression_type_from_name() {
568        assert_eq!(
569            CompressionType::from_name("none"),
570            Some(CompressionType::None)
571        );
572        #[cfg(feature = "gzip")]
573        assert_eq!(
574            CompressionType::from_name("gzip"),
575            Some(CompressionType::Gzip)
576        );
577        #[cfg(feature = "zlib")]
578        assert_eq!(
579            CompressionType::from_name("zlib"),
580            Some(CompressionType::Zlib)
581        );
582        #[cfg(feature = "zstd-compression")]
583        assert_eq!(
584            CompressionType::from_name("zstd"),
585            Some(CompressionType::Zstd)
586        );
587        assert_eq!(CompressionType::from_name("invalid"), None);
588    }
589
590    #[test]
591    fn test_compressor_no_compression() {
592        let compressor = Compressor::new(CompressionType::None);
593        let data = b"Hello, World!";
594
595        let compressed = compressor.compress(data).expect("compress should succeed");
596        assert_eq!(compressed, data);
597
598        let decompressed = compressor
599            .decompress(&compressed)
600            .expect("decompress should succeed");
601        assert_eq!(decompressed, data);
602    }
603
604    #[cfg(feature = "gzip")]
605    #[test]
606    fn test_compressor_gzip() {
607        let compressor = Compressor::new(CompressionType::Gzip).with_level(6);
608        let data = b"Hello, World!".repeat(100);
609
610        let compressed = compressor
611            .compress(&data)
612            .expect("gzip compress should succeed");
613        // Compressed should be smaller for repetitive data
614        assert!(compressed.len() < data.len());
615
616        let decompressed = compressor
617            .decompress(&compressed)
618            .expect("gzip decompress should succeed");
619        assert_eq!(decompressed, data);
620    }
621
622    #[cfg(feature = "zlib")]
623    #[test]
624    fn test_compressor_zlib() {
625        let compressor = Compressor::new(CompressionType::Zlib).with_level(6);
626        let data = b"Hello, World!".repeat(100);
627
628        let compressed = compressor
629            .compress(&data)
630            .expect("zlib compress should succeed");
631        assert!(compressed.len() < data.len());
632
633        let decompressed = compressor
634            .decompress(&compressed)
635            .expect("zlib decompress should succeed");
636        assert_eq!(decompressed, data);
637    }
638
639    #[cfg(feature = "gzip")]
640    #[test]
641    fn test_detect_gzip() {
642        let compressor = Compressor::new(CompressionType::Gzip);
643        let data = b"Test data";
644        let compressed = compressor.compress(data).expect("compress should succeed");
645
646        assert_eq!(detect_compression(&compressed), CompressionType::Gzip);
647    }
648
649    #[cfg(feature = "zstd-compression")]
650    #[test]
651    fn test_compressor_zstd() {
652        let compressor = Compressor::new(CompressionType::Zstd).with_level(3);
653        let data = b"Hello, World!".repeat(100);
654
655        let compressed = compressor
656            .compress(&data)
657            .expect("zstd compress should succeed");
658        assert!(compressed.len() < data.len());
659
660        let decompressed = compressor
661            .decompress(&compressed)
662            .expect("zstd decompress should succeed");
663        assert_eq!(decompressed, data);
664    }
665
666    #[cfg(feature = "zstd-compression")]
667    #[test]
668    fn test_detect_zstd() {
669        let compressor = Compressor::new(CompressionType::Zstd);
670        let data = b"Test data";
671        let compressed = compressor.compress(data).expect("compress should succeed");
672
673        assert_eq!(detect_compression(&compressed), CompressionType::Zstd);
674    }
675
676    #[test]
677    fn test_detect_no_compression() {
678        let data = b"Plain text data";
679        assert_eq!(detect_compression(data), CompressionType::None);
680    }
681
682    #[test]
683    fn test_auto_decompress_plain() {
684        let data = b"Plain text";
685        let result = auto_decompress(data).expect("auto_decompress should succeed");
686        assert_eq!(result, data);
687    }
688
689    #[cfg(feature = "gzip")]
690    #[test]
691    fn test_auto_decompress_gzip() {
692        let compressor = Compressor::new(CompressionType::Gzip);
693        let original = b"Test data for auto-decompress";
694        let compressed = compressor
695            .compress(original)
696            .expect("compress should succeed");
697
698        let decompressed = auto_decompress(&compressed).expect("auto_decompress should succeed");
699        assert_eq!(decompressed, original);
700    }
701
702    #[test]
703    fn test_compression_error_display() {
704        let err = CompressionError::Compress("test error".to_string());
705        assert_eq!(err.to_string(), "Compression error: test error");
706
707        let err = CompressionError::Decompress("decode failed".to_string());
708        assert_eq!(err.to_string(), "Decompression error: decode failed");
709
710        let err = CompressionError::UnsupportedType("lz4".to_string());
711        assert_eq!(err.to_string(), "Unsupported compression type: lz4");
712    }
713
714    #[test]
715    fn test_compression_type_available() {
716        let available = CompressionType::available();
717        assert!(available.contains(&CompressionType::None));
718    }
719
720    #[test]
721    fn test_compression_type_try_from() {
722        use std::convert::TryFrom;
723
724        assert_eq!(
725            CompressionType::try_from("utf-8").expect("should parse utf-8"),
726            CompressionType::None
727        );
728        assert_eq!(
729            CompressionType::try_from("identity").expect("should parse identity"),
730            CompressionType::None
731        );
732
733        #[cfg(feature = "gzip")]
734        assert_eq!(
735            CompressionType::try_from("gzip").expect("should parse gzip"),
736            CompressionType::Gzip
737        );
738
739        #[cfg(feature = "zstd-compression")]
740        assert_eq!(
741            CompressionType::try_from("zstd").expect("should parse zstd"),
742            CompressionType::Zstd
743        );
744
745        // Test error case
746        assert!(CompressionType::try_from("unknown").is_err());
747        assert!(CompressionType::try_from("lz4").is_err());
748    }
749
750    // --- CompressionRegistry tests ---
751
752    #[test]
753    fn test_registry_new() {
754        let registry = CompressionRegistry::new();
755        assert_eq!(registry.default_type(), CompressionType::None);
756        assert!(registry.is_available(&CompressionType::None));
757        assert!(!registry.available_types().is_empty());
758    }
759
760    #[test]
761    fn test_registry_with_default_none() {
762        let registry = CompressionRegistry::with_default(CompressionType::None)
763            .expect("None is always available");
764        assert_eq!(registry.default_type(), CompressionType::None);
765    }
766
767    #[cfg(feature = "gzip")]
768    #[test]
769    fn test_registry_with_default_gzip() {
770        let registry = CompressionRegistry::with_default(CompressionType::Gzip)
771            .expect("gzip should be available");
772        assert_eq!(registry.default_type(), CompressionType::Gzip);
773        assert!(registry.is_available(&CompressionType::Gzip));
774    }
775
776    #[test]
777    fn test_registry_default_impl() {
778        let registry = CompressionRegistry::default();
779        assert_eq!(registry.default_type(), CompressionType::None);
780    }
781
782    // --- CompressionStats tests ---
783
784    #[test]
785    fn test_stats_default() {
786        let stats = CompressionStats::default();
787        assert_eq!(stats.original_bytes, 0);
788        assert_eq!(stats.compressed_bytes, 0);
789        assert_eq!(stats.operations, 0);
790        assert_eq!(stats.failures, 0);
791        assert_eq!(stats.ratio(), 0.0);
792        assert_eq!(stats.savings_percent(), 0.0);
793    }
794
795    #[test]
796    fn test_stats_record() {
797        let mut stats = CompressionStats::default();
798        stats.record(1000, 500);
799
800        assert_eq!(stats.original_bytes, 1000);
801        assert_eq!(stats.compressed_bytes, 500);
802        assert_eq!(stats.operations, 1);
803        assert_eq!(stats.failures, 0);
804        assert_eq!(stats.ratio(), 0.5);
805        assert_eq!(stats.savings_percent(), 50.0);
806    }
807
808    #[test]
809    fn test_stats_multiple_records() {
810        let mut stats = CompressionStats::default();
811        stats.record(1000, 500);
812        stats.record(2000, 1000);
813
814        assert_eq!(stats.original_bytes, 3000);
815        assert_eq!(stats.compressed_bytes, 1500);
816        assert_eq!(stats.operations, 2);
817        assert_eq!(stats.ratio(), 0.5);
818    }
819
820    #[test]
821    fn test_stats_record_failure() {
822        let mut stats = CompressionStats::default();
823        stats.record_failure();
824        stats.record_failure();
825
826        assert_eq!(stats.failures, 2);
827        assert_eq!(stats.operations, 0);
828    }
829
830    #[test]
831    fn test_stats_serde_roundtrip() {
832        let mut stats = CompressionStats::default();
833        stats.record(1000, 400);
834        stats.record_failure();
835
836        let json = serde_json::to_string(&stats).expect("serialize should succeed");
837        let deserialized: CompressionStats =
838            serde_json::from_str(&json).expect("deserialize should succeed");
839
840        assert_eq!(deserialized.original_bytes, stats.original_bytes);
841        assert_eq!(deserialized.compressed_bytes, stats.compressed_bytes);
842        assert_eq!(deserialized.operations, stats.operations);
843        assert_eq!(deserialized.failures, stats.failures);
844    }
845
846    #[test]
847    fn test_compression_type_serde_roundtrip() {
848        let ct = CompressionType::None;
849        let json = serde_json::to_string(&ct).expect("serialize should succeed");
850        let deserialized: CompressionType =
851            serde_json::from_str(&json).expect("deserialize should succeed");
852        assert_eq!(deserialized, ct);
853    }
854}