Skip to main content

mediagit_compression/
adaptive.rs

1// MediaGit - Git for Media Files
2// Copyright (C) 2025 MediaGit Contributors
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published
6// by the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU Affero General Public License for more details.
13
14//! Adaptive compression based on file characteristics
15//!
16//! This module implements intelligent compression algorithm selection by analyzing:
17//! - File size (Tiny <1KB, Small <100KB, Medium <10MB, Large <100MB, Huge >100MB)
18//! - Shannon entropy (High >7.5 bits/byte = random/compressed, Low <6.0 = text)
19//! - Pattern detection (text, binary, already-compressed, media formats)
20//!
21//! The adaptive strategy:
22//! - High entropy → Store (already compressed or random)
23//! - Low entropy small files → Brotli(Best) for maximum compression
24//! - Large files → Zstd(Fast) for speed
25//! - Text files → Brotli for better ratios
26//! - Binary with patterns → Zstd for balanced performance
27
28use crate::{
29    BrotliCompressor, CompressionAlgorithm, CompressionLevel, CompressionResult, Compressor,
30    ZlibCompressor, ZstdCompressor,
31};
32use std::collections::HashMap;
33use std::sync::{Arc, Mutex};
34
35/// File size classification
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum SizeClass {
38    /// Tiny files (<1KB) - optimize for compression ratio
39    Tiny,
40    /// Small files (<100KB) - balance ratio and speed
41    Small,
42    /// Medium files (<10MB) - prefer speed
43    Medium,
44    /// Large files (<100MB) - prioritize speed
45    Large,
46    /// Huge files (>100MB) - fastest compression
47    Huge,
48}
49
50impl SizeClass {
51    /// Classify size in bytes
52    pub fn classify(size: usize) -> Self {
53        match size {
54            0..=1_024 => SizeClass::Tiny,
55            1_025..=102_400 => SizeClass::Small,
56            102_401..=10_485_760 => SizeClass::Medium,
57            10_485_761..=104_857_600 => SizeClass::Large,
58            _ => SizeClass::Huge,
59        }
60    }
61}
62
63/// Entropy classification based on Shannon entropy
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum EntropyClass {
66    /// Very low entropy (<4.0 bits/byte) - highly repetitive
67    VeryLow,
68    /// Low entropy (<6.0 bits/byte) - typical text
69    Low,
70    /// Medium entropy (<7.5 bits/byte) - mixed content
71    Medium,
72    /// High entropy (≥7.5 bits/byte) - random/compressed
73    High,
74}
75
76impl EntropyClass {
77    /// Classify entropy value (0.0 to 8.0 bits per byte)
78    pub fn classify(entropy: f64) -> Self {
79        match entropy {
80            e if e < 4.0 => EntropyClass::VeryLow,
81            e if e < 6.0 => EntropyClass::Low,
82            e if e < 7.5 => EntropyClass::Medium,
83            _ => EntropyClass::High,
84        }
85    }
86}
87
88/// Content pattern classification
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum PatternClass {
91    /// Plain text (ASCII/UTF-8)
92    Text,
93    /// Binary data with patterns
94    Binary,
95    /// Already compressed (ZIP, GZIP, etc.)
96    AlreadyCompressed,
97    /// Media formats (JPEG, PNG, MP4, etc.)
98    Media,
99}
100
101impl PatternClass {
102    /// Detect pattern from content
103    pub fn detect(data: &[u8]) -> Self {
104        if data.is_empty() {
105            return PatternClass::Binary;
106        }
107
108        // Check for common compressed/media format magic bytes
109        if Self::is_compressed_format(data) {
110            return PatternClass::AlreadyCompressed;
111        }
112        if Self::is_media_format(data) {
113            return PatternClass::Media;
114        }
115
116        // Check text ratio
117        let text_chars = data
118            .iter()
119            .take(4096)
120            .filter(|&&b| {
121                b == b'\n' || b == b'\r' || b == b'\t' || (0x20..0x7F).contains(&b) || b >= 0x80
122            })
123            .count();
124
125        let sample_size = data.len().min(4096);
126        let text_ratio = text_chars as f64 / sample_size as f64;
127
128        if text_ratio > 0.85 {
129            PatternClass::Text
130        } else {
131            PatternClass::Binary
132        }
133    }
134
135    fn is_compressed_format(data: &[u8]) -> bool {
136        if data.len() < 4 {
137            return false;
138        }
139
140        // Check common compression signatures
141        matches!(
142            &data[0..4],
143            [0x1f, 0x8b, _, _]         // GZIP
144            | [0x50, 0x4b, 0x03, 0x04] // ZIP
145            | [0x50, 0x4b, 0x05, 0x06] // ZIP empty
146            | [0x50, 0x4b, 0x07, 0x08] // ZIP spanned
147            | [0x42, 0x5a, 0x68, _]    // BZIP2
148            | [0xfd, 0x37, 0x7a, 0x58] // XZ
149            | [0x28, 0xb5, 0x2f, 0xfd] // Zstd
150        ) || (data.len() >= 2 && data[0] == 0x78) // Zlib
151    }
152
153    fn is_media_format(data: &[u8]) -> bool {
154        if data.len() < 4 {
155            return false;
156        }
157
158        // Check common media format signatures
159        matches!(
160            &data[0..4],
161            [0xff, 0xd8, 0xff, _]      // JPEG
162            | [0x89, 0x50, 0x4e, 0x47] // PNG
163            | [0x47, 0x49, 0x46, 0x38] // GIF
164            | [0x42, 0x4d, _, _]       // BMP
165            | [0x66, 0x74, 0x79, 0x70] // MP4/MOV (ftyp)
166            | [0x52, 0x49, 0x46, 0x46] // RIFF (WAV/AVI/WebP)
167        ) || (data.len() >= 12 && &data[4..12] == b"ftypMSNV") // MP4
168            || (data.len() >= 12 && &data[4..12] == b"ftypisom") // MP4
169    }
170}
171
172/// File profile for compression strategy selection
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub struct FileProfile {
175    /// Size classification
176    pub size: SizeClass,
177    /// Entropy classification
178    pub entropy: EntropyClass,
179    /// Content pattern classification
180    pub pattern: PatternClass,
181}
182
183impl FileProfile {
184    /// Create profile from data
185    pub fn analyze(data: &[u8]) -> Self {
186        let size = SizeClass::classify(data.len());
187        let entropy_value = calculate_entropy(data);
188        let entropy = EntropyClass::classify(entropy_value);
189        let pattern = PatternClass::detect(data);
190
191        FileProfile {
192            size,
193            entropy,
194            pattern,
195        }
196    }
197}
198
199/// Calculate Shannon entropy of data (bits per byte)
200///
201/// Shannon entropy H = -Σ(p(x) * log2(p(x)))
202/// where p(x) is probability of byte value x
203///
204/// Returns value between 0.0 (all same byte) and 8.0 (perfectly random)
205pub fn calculate_entropy(data: &[u8]) -> f64 {
206    if data.is_empty() {
207        return 0.0;
208    }
209
210    // Sample first 4KB for efficiency
211    let sample = &data[..data.len().min(4096)];
212
213    // Count byte frequencies
214    let mut frequencies = [0u32; 256];
215    for &byte in sample {
216        frequencies[byte as usize] += 1;
217    }
218
219    // Calculate entropy
220    let len = sample.len() as f64;
221    let mut entropy = 0.0;
222
223    for &count in &frequencies {
224        if count > 0 {
225            let probability = count as f64 / len;
226            entropy -= probability * probability.log2();
227        }
228    }
229
230    entropy
231}
232
233/// Compression strategy selection
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct CompressionStrategy {
236    /// Selected algorithm
237    pub algorithm: CompressionAlgorithm,
238    /// Selected level
239    pub level: CompressionLevel,
240}
241
242impl CompressionStrategy {
243    /// Select strategy based on file profile
244    pub fn select(profile: FileProfile) -> Self {
245        use CompressionAlgorithm::*;
246        use CompressionLevel::*;
247
248        // Already compressed or media → Store (no compression)
249        if matches!(
250            profile.pattern,
251            PatternClass::AlreadyCompressed | PatternClass::Media
252        ) {
253            return CompressionStrategy {
254                algorithm: None,
255                level: Fast,
256            };
257        }
258
259        // High entropy → likely compressed/random → Store
260        if profile.entropy == EntropyClass::High {
261            return CompressionStrategy {
262                algorithm: None,
263                level: Fast,
264            };
265        }
266
267        match (profile.size, profile.entropy, profile.pattern) {
268            // Tiny files with low entropy → Brotli(Best) for max compression
269            (SizeClass::Tiny, EntropyClass::VeryLow | EntropyClass::Low, _) => {
270                CompressionStrategy {
271                    algorithm: Brotli,
272                    level: Best,
273                }
274            }
275
276            // Small text files → Brotli(Best) for excellent ratios
277            (SizeClass::Small, EntropyClass::VeryLow | EntropyClass::Low, PatternClass::Text) => {
278                CompressionStrategy {
279                    algorithm: Brotli,
280                    level: Best,
281                }
282            }
283
284            // Small binary with low entropy → Brotli(Default)
285            (SizeClass::Small, EntropyClass::VeryLow | EntropyClass::Low, PatternClass::Binary) => {
286                CompressionStrategy {
287                    algorithm: Brotli,
288                    level: Default,
289                }
290            }
291
292            // Medium files with very low entropy → Brotli for compression
293            (SizeClass::Medium, EntropyClass::VeryLow, _) => CompressionStrategy {
294                algorithm: Brotli,
295                level: Default,
296            },
297
298            // Medium files with low entropy → Zstd(Default)
299            (SizeClass::Medium, EntropyClass::Low, _) => CompressionStrategy {
300                algorithm: Zstd,
301                level: Default,
302            },
303
304            // Large/Huge files → Zstd(Fast) for speed
305            (SizeClass::Large | SizeClass::Huge, _, _) => CompressionStrategy {
306                algorithm: Zstd,
307                level: Fast,
308            },
309
310            // Default: Zstd(Default) for balanced performance
311            _ => CompressionStrategy {
312                algorithm: Zstd,
313                level: Default,
314            },
315        }
316    }
317}
318
319/// LRU cache entry
320#[derive(Debug, Clone)]
321struct CacheEntry {
322    strategy: CompressionStrategy,
323    hits: usize,
324}
325
326/// LRU cache for strategy memoization
327#[derive(Debug)]
328struct StrategyCache {
329    cache: HashMap<FileProfile, CacheEntry>,
330    max_size: usize,
331}
332
333impl StrategyCache {
334    fn new(max_size: usize) -> Self {
335        StrategyCache {
336            cache: HashMap::new(),
337            max_size,
338        }
339    }
340
341    fn get(&mut self, profile: &FileProfile) -> Option<CompressionStrategy> {
342        if let Some(entry) = self.cache.get_mut(profile) {
343            entry.hits += 1;
344            Some(entry.strategy)
345        } else {
346            None
347        }
348    }
349
350    fn insert(&mut self, profile: FileProfile, strategy: CompressionStrategy) {
351        // Evict least used if at capacity
352        if self.cache.len() >= self.max_size {
353            if let Some((&least_used, _)) = self.cache.iter().min_by_key(|(_, entry)| entry.hits) {
354                self.cache.remove(&least_used);
355            }
356        }
357
358        self.cache.insert(profile, CacheEntry { strategy, hits: 1 });
359    }
360}
361
362/// Performance statistics
363#[derive(Debug, Clone, Default)]
364pub struct PerformanceStats {
365    /// Total compressions performed
366    pub total_compressions: usize,
367    /// Total bytes processed
368    pub total_bytes_processed: usize,
369    /// Total bytes compressed output
370    pub total_bytes_compressed: usize,
371    /// Cache hits
372    pub cache_hits: usize,
373    /// Cache misses
374    pub cache_misses: usize,
375}
376
377impl PerformanceStats {
378    /// Calculate compression ratio
379    pub fn compression_ratio(&self) -> f64 {
380        if self.total_bytes_processed == 0 {
381            return 1.0;
382        }
383        self.total_bytes_compressed as f64 / self.total_bytes_processed as f64
384    }
385
386    /// Calculate cache hit rate
387    pub fn cache_hit_rate(&self) -> f64 {
388        let total = self.cache_hits + self.cache_misses;
389        if total == 0 {
390            return 0.0;
391        }
392        self.cache_hits as f64 / total as f64
393    }
394}
395
396/// Adaptive compressor with intelligent algorithm selection
397#[derive(Debug)]
398pub struct AdaptiveCompressor {
399    zstd: ZstdCompressor,
400    brotli: BrotliCompressor,
401    zlib: ZlibCompressor,
402    cache: Arc<Mutex<StrategyCache>>,
403    stats: Arc<Mutex<PerformanceStats>>,
404}
405
406impl AdaptiveCompressor {
407    /// Create new adaptive compressor with default cache size
408    pub fn new() -> Self {
409        Self::with_cache_size(1000)
410    }
411
412    /// Create with specific cache size
413    pub fn with_cache_size(cache_size: usize) -> Self {
414        AdaptiveCompressor {
415            zstd: ZstdCompressor::new(CompressionLevel::Default),
416            brotli: BrotliCompressor::new(CompressionLevel::Default),
417            zlib: ZlibCompressor::new(CompressionLevel::Default),
418            cache: Arc::new(Mutex::new(StrategyCache::new(cache_size))),
419            stats: Arc::new(Mutex::new(PerformanceStats::default())),
420        }
421    }
422
423    /// Select compression strategy for data
424    pub fn select_strategy(&self, data: &[u8]) -> CompressionStrategy {
425        let profile = FileProfile::analyze(data);
426
427        // Check cache first
428        if let Ok(mut cache) = self.cache.lock() {
429            if let Some(strategy) = cache.get(&profile) {
430                if let Ok(mut stats) = self.stats.lock() {
431                    stats.cache_hits += 1;
432                }
433                return strategy;
434            }
435        }
436
437        // Compute strategy
438        let strategy = CompressionStrategy::select(profile);
439
440        // Update cache
441        if let Ok(mut cache) = self.cache.lock() {
442            cache.insert(profile, strategy);
443        }
444        if let Ok(mut stats) = self.stats.lock() {
445            stats.cache_misses += 1;
446        }
447
448        strategy
449    }
450
451    /// Get performance statistics
452    pub fn stats(&self) -> PerformanceStats {
453        self.stats
454            .lock()
455            .ok()
456            .map(|s| s.clone())
457            .unwrap_or_default()
458    }
459
460    /// Reset statistics
461    pub fn reset_stats(&self) {
462        if let Ok(mut stats) = self.stats.lock() {
463            *stats = PerformanceStats::default();
464        }
465    }
466}
467
468impl Default for AdaptiveCompressor {
469    fn default() -> Self {
470        Self::new()
471    }
472}
473
474impl Compressor for AdaptiveCompressor {
475    fn compress(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
476        let strategy = self.select_strategy(data);
477
478        let result = match strategy.algorithm {
479            CompressionAlgorithm::None => Ok(data.to_vec()),
480            CompressionAlgorithm::Zlib => {
481                let compressor = ZlibCompressor::new(strategy.level);
482                compressor.compress(data)
483            }
484            CompressionAlgorithm::Zstd => {
485                let compressor = ZstdCompressor::new(strategy.level);
486                compressor.compress(data)
487            }
488            CompressionAlgorithm::Brotli => {
489                let compressor = BrotliCompressor::new(strategy.level);
490                compressor.compress(data)
491            }
492        };
493
494        // Update stats
495        if let Ok(compressed) = &result {
496            if let Ok(mut stats) = self.stats.lock() {
497                stats.total_compressions += 1;
498                stats.total_bytes_processed += data.len();
499                stats.total_bytes_compressed += compressed.len();
500            }
501        }
502
503        result
504    }
505
506    fn decompress(&self, data: &[u8]) -> CompressionResult<Vec<u8>> {
507        // Auto-detect algorithm and delegate
508        let algorithm = CompressionAlgorithm::detect(data);
509
510        match algorithm {
511            CompressionAlgorithm::None => Ok(data.to_vec()),
512            CompressionAlgorithm::Zlib => self.zlib.decompress(data),
513            CompressionAlgorithm::Zstd => self.zstd.decompress(data),
514            CompressionAlgorithm::Brotli => self.brotli.decompress(data),
515        }
516    }
517}
518
519#[cfg(test)]
520#[allow(clippy::unwrap_used)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_size_classification() {
526        assert_eq!(SizeClass::classify(500), SizeClass::Tiny);
527        assert_eq!(SizeClass::classify(1024), SizeClass::Tiny);
528        assert_eq!(SizeClass::classify(1025), SizeClass::Small);
529        assert_eq!(SizeClass::classify(50_000), SizeClass::Small);
530        assert_eq!(SizeClass::classify(102_400), SizeClass::Small);
531        assert_eq!(SizeClass::classify(102_401), SizeClass::Medium);
532        assert_eq!(SizeClass::classify(5_000_000), SizeClass::Medium);
533        assert_eq!(SizeClass::classify(10_485_760), SizeClass::Medium);
534        assert_eq!(SizeClass::classify(10_485_761), SizeClass::Large);
535        assert_eq!(SizeClass::classify(50_000_000), SizeClass::Large);
536        assert_eq!(SizeClass::classify(104_857_600), SizeClass::Large);
537        assert_eq!(SizeClass::classify(104_857_601), SizeClass::Huge);
538    }
539
540    #[test]
541    fn test_entropy_classification() {
542        assert_eq!(EntropyClass::classify(3.5), EntropyClass::VeryLow);
543        assert_eq!(EntropyClass::classify(3.99), EntropyClass::VeryLow);
544        assert_eq!(EntropyClass::classify(4.0), EntropyClass::Low);
545        assert_eq!(EntropyClass::classify(5.5), EntropyClass::Low);
546        assert_eq!(EntropyClass::classify(5.99), EntropyClass::Low);
547        assert_eq!(EntropyClass::classify(6.0), EntropyClass::Medium);
548        assert_eq!(EntropyClass::classify(7.0), EntropyClass::Medium);
549        assert_eq!(EntropyClass::classify(7.49), EntropyClass::Medium);
550        assert_eq!(EntropyClass::classify(7.5), EntropyClass::High);
551        assert_eq!(EntropyClass::classify(8.0), EntropyClass::High);
552    }
553
554    #[test]
555    fn test_entropy_calculation() {
556        // All same byte → entropy = 0
557        let uniform = vec![0u8; 1000];
558        let entropy = calculate_entropy(&uniform);
559        assert!(entropy < 0.01);
560
561        // Random bytes → entropy ≈ 8
562        let random: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
563        let entropy = calculate_entropy(&random);
564        assert!(entropy > 5.0); // Should be high but not necessarily 8.0
565
566        // Text → entropy 4-6
567        let text = b"Hello, World! This is a test of Shannon entropy calculation.";
568        let entropy = calculate_entropy(text);
569        assert!(entropy > 3.0 && entropy < 6.0);
570    }
571
572    #[test]
573    fn test_pattern_detection_text() {
574        let text = b"This is plain text content with newlines\nand tabs\t.";
575        assert_eq!(PatternClass::detect(text), PatternClass::Text);
576    }
577
578    #[test]
579    fn test_pattern_detection_compressed() {
580        let gzip = b"\x1f\x8b\x08\x00\x00\x00\x00\x00";
581        assert_eq!(PatternClass::detect(gzip), PatternClass::AlreadyCompressed);
582
583        let zip = b"\x50\x4b\x03\x04\x00\x00\x00\x00";
584        assert_eq!(PatternClass::detect(zip), PatternClass::AlreadyCompressed);
585
586        let zstd = b"\x28\xb5\x2f\xfd\x00\x00\x00\x00";
587        assert_eq!(PatternClass::detect(zstd), PatternClass::AlreadyCompressed);
588    }
589
590    #[test]
591    fn test_pattern_detection_media() {
592        let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
593        assert_eq!(PatternClass::detect(jpeg), PatternClass::Media);
594
595        let png = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a";
596        assert_eq!(PatternClass::detect(png), PatternClass::Media);
597
598        let gif = b"\x47\x49\x46\x38\x39\x61";
599        assert_eq!(PatternClass::detect(gif), PatternClass::Media);
600    }
601
602    #[test]
603    fn test_strategy_selection_high_entropy() {
604        // High entropy → Store
605        let profile = FileProfile {
606            size: SizeClass::Medium,
607            entropy: EntropyClass::High,
608            pattern: PatternClass::Binary,
609        };
610        let strategy = CompressionStrategy::select(profile);
611        assert_eq!(strategy.algorithm, CompressionAlgorithm::None);
612    }
613
614    #[test]
615    fn test_strategy_selection_compressed() {
616        // Already compressed → Store
617        let profile = FileProfile {
618            size: SizeClass::Small,
619            entropy: EntropyClass::Low,
620            pattern: PatternClass::AlreadyCompressed,
621        };
622        let strategy = CompressionStrategy::select(profile);
623        assert_eq!(strategy.algorithm, CompressionAlgorithm::None);
624    }
625
626    #[test]
627    fn test_strategy_selection_tiny_text() {
628        // Tiny text with low entropy → Brotli(Best)
629        let profile = FileProfile {
630            size: SizeClass::Tiny,
631            entropy: EntropyClass::Low,
632            pattern: PatternClass::Text,
633        };
634        let strategy = CompressionStrategy::select(profile);
635        assert_eq!(strategy.algorithm, CompressionAlgorithm::Brotli);
636        assert_eq!(strategy.level, CompressionLevel::Best);
637    }
638
639    #[test]
640    fn test_strategy_selection_large_file() {
641        // Large file → Zstd(Fast)
642        let profile = FileProfile {
643            size: SizeClass::Large,
644            entropy: EntropyClass::Low,
645            pattern: PatternClass::Binary,
646        };
647        let strategy = CompressionStrategy::select(profile);
648        assert_eq!(strategy.algorithm, CompressionAlgorithm::Zstd);
649        assert_eq!(strategy.level, CompressionLevel::Fast);
650    }
651
652    #[test]
653    fn test_file_profile_analysis() {
654        // Text file
655        let text = b"Hello, World! This is a test.";
656        let profile = FileProfile::analyze(text);
657        assert_eq!(profile.size, SizeClass::Tiny);
658        assert_eq!(profile.pattern, PatternClass::Text);
659
660        // JPEG file
661        let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00";
662        let profile = FileProfile::analyze(jpeg);
663        assert_eq!(profile.pattern, PatternClass::Media);
664    }
665
666    #[test]
667    fn test_adaptive_compressor_text() {
668        let compressor = AdaptiveCompressor::new();
669        let text = b"Hello, World! ".repeat(100); // ~1.4KB
670
671        let compressed = compressor.compress(&text).unwrap();
672        let decompressed = compressor.decompress(&compressed).unwrap();
673
674        assert_eq!(&text[..], &decompressed[..]);
675        assert!(compressed.len() < text.len());
676    }
677
678    #[test]
679    fn test_adaptive_compressor_random() {
680        let compressor = AdaptiveCompressor::new();
681        // High entropy data (pseudo-random)
682        let random: Vec<u8> = (0..1000).map(|i| ((i * 7 + 13) % 256) as u8).collect();
683
684        let compressed = compressor.compress(&random).unwrap();
685        let decompressed = compressor.decompress(&compressed).unwrap();
686
687        assert_eq!(&random[..], &decompressed[..]);
688        // High entropy may not compress well
689    }
690
691    #[test]
692    fn test_adaptive_compressor_cache() {
693        let compressor = AdaptiveCompressor::new();
694        let text = b"Test data";
695
696        // First compression → cache miss
697        compressor.compress(text).unwrap();
698        let stats1 = compressor.stats();
699        assert_eq!(stats1.cache_misses, 1);
700        assert_eq!(stats1.cache_hits, 0);
701
702        // Second compression of similar profile → cache hit
703        compressor.compress(text).unwrap();
704        let stats2 = compressor.stats();
705        assert_eq!(stats2.cache_hits, 1);
706    }
707
708    #[test]
709    fn test_performance_stats() {
710        let compressor = AdaptiveCompressor::new();
711        let data1 = b"Hello, World!".repeat(10);
712        let data2 = b"Test data".repeat(20);
713
714        compressor.compress(&data1).unwrap();
715        compressor.compress(&data2).unwrap();
716
717        let stats = compressor.stats();
718        assert_eq!(stats.total_compressions, 2);
719        assert!(stats.total_bytes_processed > 0);
720        assert!(stats.total_bytes_compressed > 0);
721        assert!(stats.compression_ratio() < 1.0); // Should compress
722    }
723
724    #[test]
725    fn test_strategy_cache_lru_eviction() {
726        let mut cache = StrategyCache::new(2);
727
728        let profile1 = FileProfile {
729            size: SizeClass::Tiny,
730            entropy: EntropyClass::Low,
731            pattern: PatternClass::Text,
732        };
733        let profile2 = FileProfile {
734            size: SizeClass::Small,
735            entropy: EntropyClass::Medium,
736            pattern: PatternClass::Binary,
737        };
738        let profile3 = FileProfile {
739            size: SizeClass::Large,
740            entropy: EntropyClass::High,
741            pattern: PatternClass::Binary,
742        };
743
744        let strategy = CompressionStrategy {
745            algorithm: CompressionAlgorithm::Zstd,
746            level: CompressionLevel::Default,
747        };
748
749        // Insert two entries
750        cache.insert(profile1, strategy);
751        cache.insert(profile2, strategy);
752        assert_eq!(cache.cache.len(), 2);
753
754        // Access profile1 to increase its hit count
755        cache.get(&profile1);
756
757        // Insert third entry → should evict profile2 (least used)
758        cache.insert(profile3, strategy);
759        assert_eq!(cache.cache.len(), 2);
760        assert!(cache.cache.contains_key(&profile1));
761        assert!(cache.cache.contains_key(&profile3));
762        assert!(!cache.cache.contains_key(&profile2));
763    }
764}