Skip to main content

mediagit_compression/
delta.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//! Delta encoding implementation using bsdiff
15//!
16//! This module provides delta compression for incremental changes to large media files,
17//! achieving up to 95% space savings when files are similar.
18//!
19//! # Features
20//!
21//! - bsdiff-based binary diff/patch operations
22//! - Intelligent delta vs full compression decision logic
23//! - Base reference tracking with chain depth limits
24//! - Fast delta application with chain resolution (<100ms)
25//! - Similarity detection (>80% threshold)
26//!
27//! # Example
28//!
29//! ```rust
30//! use mediagit_compression::delta::{DeltaEncoder, DeltaMetadata};
31//!
32//! # fn example() -> anyhow::Result<()> {
33//! let encoder = DeltaEncoder::new();
34//!
35//! let base = b"Hello, World!";
36//! let modified = b"Hello, MediaGit World!";
37//!
38//! // Create delta
39//! let (delta_data, should_use_delta) = encoder.encode(base, modified)?;
40//!
41//! if should_use_delta {
42//!     // Apply delta to reconstruct
43//!     let reconstructed = encoder.decode(base, &delta_data)?;
44//!     assert_eq!(modified, &reconstructed[..]);
45//! }
46//! # Ok(())
47//! # }
48//! ```
49
50use crate::error::{CompressionError, CompressionResult};
51use crate::smart_compressor::{ObjectCategory, ObjectType};
52use serde::{Deserialize, Serialize};
53use std::collections::HashMap;
54use std::time::Instant;
55use tracing::{debug, info, trace, warn};
56
57/// Maximum delta chain depth to prevent excessive reconstruction time
58pub const MAX_CHAIN_DEPTH: u32 = 10;
59
60/// Similarity threshold for delta encoding (80% means files must be at least 80% similar)
61pub const SIMILARITY_THRESHOLD: f64 = 0.80;
62
63/// Target space savings for using delta encoding (if savings < 10%, use full compression)
64pub const MIN_SPACE_SAVINGS: f64 = 0.10;
65
66/// Maximum reconstruction time target (100ms)
67pub const MAX_RECONSTRUCTION_TIME_MS: u64 = 100;
68
69/// Media-aware delta configuration
70#[derive(Debug, Clone, Copy)]
71pub struct DeltaConfig {
72    /// Similarity threshold (0.0-1.0)
73    pub similarity_threshold: f64,
74    /// Minimum space savings (0.0-1.0)
75    pub min_space_savings: f64,
76    /// Maximum chain depth
77    pub max_chain_depth: u32,
78}
79
80impl DeltaConfig {
81    /// Get configuration for object type
82    pub fn for_object_type(obj_type: ObjectType) -> Self {
83        match obj_type.category() {
84            // Images: higher similarity threshold, more aggressive savings
85            ObjectCategory::Image => DeltaConfig {
86                similarity_threshold: 0.85,
87                min_space_savings: 0.15,
88                max_chain_depth: 5,
89            },
90            // Video: very high similarity needed (metadata changes only)
91            ObjectCategory::Video => DeltaConfig {
92                similarity_threshold: 0.95,
93                min_space_savings: 0.05,
94                max_chain_depth: 3,
95            },
96            // Audio: moderate similarity
97            ObjectCategory::Audio => DeltaConfig {
98                similarity_threshold: 0.90,
99                min_space_savings: 0.10,
100                max_chain_depth: 5,
101            },
102            // Text/Code: very effective delta compression
103            ObjectCategory::Text | ObjectCategory::Document => DeltaConfig {
104                similarity_threshold: 0.70,
105                min_space_savings: 0.10,
106                max_chain_depth: MAX_CHAIN_DEPTH,
107            },
108            // Creative project files: HIGHEST PRIORITY for delta compression
109            // These files see frequent incremental edits (layers, timelines, scenes)
110            // Delta compression can achieve 80-95% space savings between versions
111            ObjectCategory::CreativeProject => DeltaConfig {
112                similarity_threshold: 0.70, // Lower threshold (frequent edits)
113                min_space_savings: 0.10,
114                max_chain_depth: 10, // Allow longer chains for project history
115            },
116            // Office documents: moderate delta effectiveness (XML-based)
117            ObjectCategory::Office => DeltaConfig {
118                similarity_threshold: 0.75,
119                min_space_savings: 0.10,
120                max_chain_depth: MAX_CHAIN_DEPTH,
121            },
122            // ML specialized: differentiate training vs inference
123            // Training checkpoints benefit heavily from delta (incremental learning)
124            ObjectCategory::MlSpecialized => DeltaConfig {
125                similarity_threshold: 0.75, // Model weights change gradually
126                min_space_savings: 0.10,
127                max_chain_depth: 8, // Moderate chains for training runs
128            },
129            // Database: conservative approach
130            ObjectCategory::Database => DeltaConfig {
131                similarity_threshold: 0.80,
132                min_space_savings: 0.10,
133                max_chain_depth: 5,
134            },
135            // Git objects: optimized for source code
136            ObjectCategory::GitObject => DeltaConfig {
137                similarity_threshold: 0.75,
138                min_space_savings: 0.10,
139                max_chain_depth: MAX_CHAIN_DEPTH,
140            },
141            // Archives/Unknown: conservative
142            ObjectCategory::Archive | ObjectCategory::Unknown => DeltaConfig {
143                similarity_threshold: SIMILARITY_THRESHOLD,
144                min_space_savings: MIN_SPACE_SAVINGS,
145                max_chain_depth: MAX_CHAIN_DEPTH,
146            },
147        }
148    }
149
150    /// Default configuration
151    pub fn default_config() -> Self {
152        DeltaConfig {
153            similarity_threshold: SIMILARITY_THRESHOLD,
154            min_space_savings: MIN_SPACE_SAVINGS,
155            max_chain_depth: MAX_CHAIN_DEPTH,
156        }
157    }
158}
159
160/// Delta encoding metadata
161///
162/// Tracks base references, chain depth, and compression statistics
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164pub struct DeltaMetadata {
165    /// Hash of the base version used for this delta
166    pub base_hash: String,
167    /// Chain depth (0 = full version, >0 = delta)
168    pub chain_depth: u32,
169    /// Original size before delta compression
170    pub original_size: usize,
171    /// Delta size (or full compressed size if not using delta)
172    pub delta_size: usize,
173    /// Whether this version uses delta encoding
174    pub is_delta: bool,
175    /// Similarity score with base (0.0 - 1.0)
176    pub similarity: f64,
177    /// Space savings percentage
178    pub space_savings: f64,
179}
180
181impl DeltaMetadata {
182    /// Create metadata for a full (non-delta) version
183    pub fn full(hash: String, size: usize) -> Self {
184        DeltaMetadata {
185            base_hash: hash,
186            chain_depth: 0,
187            original_size: size,
188            delta_size: size,
189            is_delta: false,
190            similarity: 1.0,
191            space_savings: 0.0,
192        }
193    }
194
195    /// Create metadata for a delta version
196    pub fn delta(
197        base_hash: String,
198        base_chain_depth: u32,
199        original_size: usize,
200        delta_size: usize,
201        similarity: f64,
202    ) -> Self {
203        let space_savings = if original_size > 0 {
204            (original_size - delta_size) as f64 / original_size as f64
205        } else {
206            0.0
207        };
208
209        DeltaMetadata {
210            base_hash,
211            chain_depth: base_chain_depth + 1,
212            original_size,
213            delta_size,
214            is_delta: true,
215            similarity,
216            space_savings,
217        }
218    }
219
220    /// Check if this version is at max chain depth
221    pub fn is_at_max_depth(&self) -> bool {
222        self.chain_depth >= MAX_CHAIN_DEPTH
223    }
224
225    /// Calculate compression ratio
226    pub fn compression_ratio(&self) -> f64 {
227        if self.original_size == 0 {
228            1.0
229        } else {
230            self.delta_size as f64 / self.original_size as f64
231        }
232    }
233}
234
235/// Delta chain tracker
236///
237/// Manages base references and prevents excessive chain depth
238#[derive(Debug, Clone)]
239pub struct DeltaChainTracker {
240    /// Map of hash -> metadata
241    metadata: HashMap<String, DeltaMetadata>,
242}
243
244impl DeltaChainTracker {
245    /// Create a new delta chain tracker
246    pub fn new() -> Self {
247        DeltaChainTracker {
248            metadata: HashMap::new(),
249        }
250    }
251
252    /// Register a new version
253    pub fn register(&mut self, hash: String, metadata: DeltaMetadata) {
254        self.metadata.insert(hash, metadata);
255    }
256
257    /// Get metadata for a version
258    pub fn get(&self, hash: &str) -> Option<&DeltaMetadata> {
259        self.metadata.get(hash)
260    }
261
262    /// Find the best base for a new version
263    ///
264    /// Returns the hash of the best base, or None if no suitable base exists
265    pub fn find_best_base(&self, candidates: &[String]) -> Option<String> {
266        candidates
267            .iter()
268            .filter_map(|hash| {
269                self.metadata
270                    .get(hash)
271                    .map(|meta| (hash.clone(), meta.chain_depth))
272            })
273            .min_by_key(|(_, depth)| *depth)
274            .filter(|(_, depth)| *depth < MAX_CHAIN_DEPTH)
275            .map(|(hash, _)| hash)
276    }
277
278    /// Get the full chain for reconstruction
279    pub fn get_chain(&self, hash: &str) -> CompressionResult<Vec<String>> {
280        let mut chain = Vec::new();
281        let mut current = hash.to_string();
282
283        loop {
284            chain.push(current.clone());
285
286            if let Some(meta) = self.metadata.get(&current) {
287                if meta.chain_depth == 0 {
288                    // Reached base
289                    break;
290                }
291                current = meta.base_hash.clone();
292            } else {
293                return Err(CompressionError::invalid_input(format!(
294                    "missing metadata for hash: {}",
295                    current
296                )));
297            }
298        }
299
300        // Reverse so base is first
301        chain.reverse();
302        Ok(chain)
303    }
304}
305
306impl Default for DeltaChainTracker {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312/// Delta encoder using bsdiff
313///
314/// Provides delta compression and decompression with intelligent decision logic,
315/// with optional media-aware configuration.
316#[derive(Debug, Clone)]
317pub struct DeltaEncoder {
318    /// Minimum similarity threshold for using delta encoding
319    similarity_threshold: f64,
320    /// Minimum space savings for using delta encoding
321    min_space_savings: f64,
322    /// Maximum chain depth
323    max_chain_depth: u32,
324}
325
326impl DeltaEncoder {
327    /// Create a new delta encoder with default settings
328    pub fn new() -> Self {
329        DeltaEncoder {
330            similarity_threshold: SIMILARITY_THRESHOLD,
331            min_space_savings: MIN_SPACE_SAVINGS,
332            max_chain_depth: MAX_CHAIN_DEPTH,
333        }
334    }
335
336    /// Create a delta encoder with custom thresholds
337    pub fn with_thresholds(similarity_threshold: f64, min_space_savings: f64) -> Self {
338        DeltaEncoder {
339            similarity_threshold,
340            min_space_savings,
341            max_chain_depth: MAX_CHAIN_DEPTH,
342        }
343    }
344
345    /// Encode a delta between base and target
346    ///
347    /// Returns (delta_data, should_use_delta)
348    ///
349    /// # Arguments
350    ///
351    /// * `base` - Base version data
352    /// * `target` - Target version data
353    ///
354    /// # Returns
355    ///
356    /// Tuple of (delta_data, should_use_delta). If should_use_delta is false,
357    /// delta_data contains the full target data instead.
358    pub fn encode(&self, base: &[u8], target: &[u8]) -> CompressionResult<(Vec<u8>, bool)> {
359        self.encode_with_depth(base, target, 0)
360    }
361
362    /// Encode a delta between base and target with chain depth tracking
363    ///
364    /// Returns (delta_data, should_use_delta)
365    ///
366    /// # Arguments
367    ///
368    /// * `base` - Base version data
369    /// * `target` - Target version data
370    /// * `current_chain_depth` - Current depth in the delta chain
371    ///
372    /// # Returns
373    ///
374    /// Tuple of (delta_data, should_use_delta). If should_use_delta is false,
375    /// delta_data contains the full target data instead.
376    ///
377    /// # Delta Chain Depth
378    ///
379    /// When the chain depth exceeds `max_chain_depth`, the encoder will return
380    /// the full object instead of creating another delta. This prevents
381    /// excessively long delta chains that can hurt decompression performance.
382    pub fn encode_with_depth(
383        &self,
384        base: &[u8],
385        target: &[u8],
386        current_chain_depth: u32,
387    ) -> CompressionResult<(Vec<u8>, bool)> {
388        let start = Instant::now();
389
390        // Check chain depth limit
391        if current_chain_depth >= self.max_chain_depth {
392            debug!(
393                "Chain depth {} reached max depth {}, using full compression",
394                current_chain_depth, self.max_chain_depth
395            );
396            return Ok((target.to_vec(), false));
397        }
398
399        // Calculate similarity
400        let similarity = self.calculate_similarity(base, target);
401        trace!(
402            "Similarity between base and target: {:.2}%",
403            similarity * 100.0
404        );
405
406        // Check similarity threshold
407        if similarity < self.similarity_threshold {
408            debug!(
409                "Similarity {:.2}% below threshold {:.2}%, using full compression",
410                similarity * 100.0,
411                self.similarity_threshold * 100.0
412            );
413            return Ok((target.to_vec(), false));
414        }
415
416        // Create delta using bsdiff, then compress with zstd
417        // (bsdiff v0.2 does not compress internally; raw patches are mostly zeros for
418        // similar files, so zstd compresses them extremely well)
419        let delta = {
420            let mut raw_patch = Vec::new();
421            match bsdiff::diff(base, target, &mut raw_patch) {
422                Ok(()) => zstd::bulk::compress(&raw_patch, 3).unwrap_or(raw_patch),
423                Err(e) => {
424                    warn!("bsdiff encoding failed: {}, falling back to full", e);
425                    return Ok((target.to_vec(), false));
426                }
427            }
428        };
429
430        // Calculate space savings
431        let space_savings = if !target.is_empty() && delta.len() < target.len() {
432            (target.len() - delta.len()) as f64 / target.len() as f64
433        } else if !target.is_empty() && delta.len() >= target.len() {
434            // Delta is larger than target, negative savings
435            -((delta.len() - target.len()) as f64 / target.len() as f64)
436        } else {
437            0.0
438        };
439
440        // Check if delta is beneficial
441        if space_savings < self.min_space_savings {
442            debug!(
443                "Space savings {:.2}% below threshold {:.2}%, using full compression",
444                space_savings * 100.0,
445                self.min_space_savings * 100.0
446            );
447            return Ok((target.to_vec(), false));
448        }
449
450        let elapsed = start.elapsed();
451        info!(
452            "Delta encoding: {} bytes -> {} bytes ({:.1}% savings) in {:?}",
453            target.len(),
454            delta.len(),
455            space_savings * 100.0,
456            elapsed
457        );
458
459        Ok((delta, true))
460    }
461
462    /// Decode a delta to reconstruct the target
463    ///
464    /// # Arguments
465    ///
466    /// * `base` - Base version data
467    /// * `delta` - Delta data
468    ///
469    /// # Returns
470    ///
471    /// Reconstructed target data
472    pub fn decode(&self, base: &[u8], delta: &[u8]) -> CompressionResult<Vec<u8>> {
473        let start = Instant::now();
474
475        let result = {
476            // Decompress zstd wrapper, then apply bsdiff patch
477            let raw_patch = zstd::bulk::decompress(delta, 128 * 1024 * 1024).map_err(|e| {
478                CompressionError::decompression_failed(format!(
479                    "bsdiff zstd decompression failed: {}",
480                    e
481                ))
482            })?;
483            let mut output = Vec::new();
484            bsdiff::patch(base, &mut raw_patch.as_slice(), &mut output).map_err(|e| {
485                CompressionError::decompression_failed(format!("bsdiff patch failed: {}", e))
486            })?;
487            output
488        };
489
490        let elapsed = start.elapsed();
491        if elapsed.as_millis() > MAX_RECONSTRUCTION_TIME_MS as u128 {
492            warn!(
493                "Delta reconstruction took {:?}, exceeds target of {}ms",
494                elapsed, MAX_RECONSTRUCTION_TIME_MS
495            );
496        }
497
498        trace!(
499            "Delta decoding: {} bytes delta + {} bytes base -> {} bytes target in {:?}",
500            delta.len(),
501            base.len(),
502            result.len(),
503            elapsed
504        );
505
506        Ok(result)
507    }
508
509    /// Apply a delta chain to reconstruct the final version
510    ///
511    /// # Arguments
512    ///
513    /// * `versions` - Ordered list of (data, is_delta) tuples, base first
514    ///
515    /// # Returns
516    ///
517    /// Reconstructed final version
518    pub fn decode_chain(&self, versions: &[(Vec<u8>, bool)]) -> CompressionResult<Vec<u8>> {
519        if versions.is_empty() {
520            return Err(CompressionError::invalid_input("empty version chain"));
521        }
522
523        let start = Instant::now();
524        let mut current = versions[0].0.clone();
525
526        for (i, (data, is_delta)) in versions.iter().enumerate().skip(1) {
527            if *is_delta {
528                trace!("Applying delta {} in chain", i);
529                current = self.decode(&current, data)?;
530            } else {
531                // Full version, replace current
532                current = data.clone();
533            }
534        }
535
536        let elapsed = start.elapsed();
537        if elapsed.as_millis() > MAX_RECONSTRUCTION_TIME_MS as u128 {
538            warn!(
539                "Delta chain reconstruction took {:?}, exceeds target of {}ms",
540                elapsed, MAX_RECONSTRUCTION_TIME_MS
541            );
542        }
543
544        info!(
545            "Delta chain reconstruction: {} versions -> {} bytes in {:?}",
546            versions.len(),
547            current.len(),
548            elapsed
549        );
550
551        Ok(current)
552    }
553
554    /// Calculate similarity between two byte arrays
555    ///
556    /// Uses a simple byte-level similarity metric based on longest common subsequence
557    /// approximation. For production use, consider more sophisticated algorithms.
558    fn calculate_similarity(&self, base: &[u8], target: &[u8]) -> f64 {
559        if base.is_empty() && target.is_empty() {
560            return 1.0;
561        }
562
563        if base.is_empty() || target.is_empty() {
564            return 0.0;
565        }
566
567        // For performance, use a sampling-based similarity metric
568        // This is a fast approximation that works well for large files
569        let sample_size = 1024.min(base.len().min(target.len()));
570        let mut matches = 0;
571
572        // Sample at regular intervals
573        let base_step = if base.len() > sample_size {
574            base.len() / sample_size
575        } else {
576            1
577        };
578        let target_step = if target.len() > sample_size {
579            target.len() / sample_size
580        } else {
581            1
582        };
583
584        for i in 0..sample_size {
585            let base_idx = (i * base_step).min(base.len() - 1);
586            let target_idx = (i * target_step).min(target.len() - 1);
587
588            if base[base_idx] == target[target_idx] {
589                matches += 1;
590            }
591        }
592
593        matches as f64 / sample_size as f64
594    }
595
596    /// Calculate hash for data (for tracking purposes)
597    pub fn hash_data(data: &[u8]) -> String {
598        let hash = blake3::hash(data);
599        hash.to_hex().to_string()
600    }
601
602    /// Get the maximum chain depth setting
603    pub fn max_chain_depth(&self) -> u32 {
604        self.max_chain_depth
605    }
606
607    /// Set the maximum chain depth
608    ///
609    /// When encoding deltas, if the current chain depth reaches this value,
610    /// the encoder will store the full object instead of creating another delta.
611    ///
612    /// # Arguments
613    ///
614    /// * `depth` - Maximum allowed chain depth (typically 50-100)
615    ///
616    /// # Example
617    ///
618    /// ```
619    /// use mediagit_compression::delta::DeltaEncoder;
620    ///
621    /// let mut encoder = DeltaEncoder::new();
622    /// encoder.set_max_chain_depth(75);
623    /// assert_eq!(encoder.max_chain_depth(), 75);
624    /// ```
625    pub fn set_max_chain_depth(&mut self, depth: u32) {
626        self.max_chain_depth = depth;
627    }
628
629    /// Create a delta encoder with custom chain depth
630    ///
631    /// # Arguments
632    ///
633    /// * `max_chain_depth` - Maximum allowed chain depth
634    ///
635    /// # Example
636    ///
637    /// ```
638    /// use mediagit_compression::delta::DeltaEncoder;
639    ///
640    /// let encoder = DeltaEncoder::with_max_chain_depth(100);
641    /// assert_eq!(encoder.max_chain_depth(), 100);
642    /// ```
643    pub fn with_max_chain_depth(max_chain_depth: u32) -> Self {
644        DeltaEncoder {
645            similarity_threshold: SIMILARITY_THRESHOLD,
646            min_space_savings: MIN_SPACE_SAVINGS,
647            max_chain_depth,
648        }
649    }
650}
651
652impl Default for DeltaEncoder {
653    fn default() -> Self {
654        Self::new()
655    }
656}
657
658#[cfg(test)]
659#[allow(clippy::unwrap_used)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_delta_metadata_full() {
665        let meta = DeltaMetadata::full("abc123".to_string(), 1000);
666        assert_eq!(meta.chain_depth, 0);
667        assert!(!meta.is_delta);
668        assert_eq!(meta.similarity, 1.0);
669        assert_eq!(meta.compression_ratio(), 1.0);
670    }
671
672    #[test]
673    fn test_delta_metadata_delta() {
674        let meta = DeltaMetadata::delta("abc123".to_string(), 0, 1000, 100, 0.95);
675        assert_eq!(meta.chain_depth, 1);
676        assert!(meta.is_delta);
677        assert_eq!(meta.similarity, 0.95);
678        assert_eq!(meta.space_savings, 0.9);
679        assert_eq!(meta.compression_ratio(), 0.1);
680    }
681
682    #[test]
683    fn test_delta_metadata_max_depth() {
684        let meta = DeltaMetadata::delta("abc".to_string(), MAX_CHAIN_DEPTH - 1, 1000, 100, 0.9);
685        assert!(meta.is_at_max_depth());
686
687        let meta2 = DeltaMetadata::delta("abc".to_string(), 5, 1000, 100, 0.9);
688        assert!(!meta2.is_at_max_depth());
689    }
690
691    #[test]
692    fn test_delta_chain_tracker() {
693        let mut tracker = DeltaChainTracker::new();
694
695        let meta1 = DeltaMetadata::full("base".to_string(), 1000);
696        tracker.register("hash1".to_string(), meta1.clone());
697
698        let meta2 = DeltaMetadata::delta("hash1".to_string(), 0, 900, 100, 0.95);
699        tracker.register("hash2".to_string(), meta2);
700
701        assert_eq!(tracker.get("hash1"), Some(&meta1));
702        assert!(tracker.get("hash2").is_some());
703        assert!(tracker.get("nonexistent").is_none());
704    }
705
706    #[test]
707    fn test_find_best_base() {
708        let mut tracker = DeltaChainTracker::new();
709
710        tracker.register(
711            "base".to_string(),
712            DeltaMetadata::full("".to_string(), 1000),
713        );
714        tracker.register(
715            "v1".to_string(),
716            DeltaMetadata::delta("base".to_string(), 0, 900, 100, 0.9),
717        );
718        tracker.register(
719            "v2".to_string(),
720            DeltaMetadata::delta("v1".to_string(), 1, 850, 50, 0.85),
721        );
722
723        let candidates = vec!["base".to_string(), "v1".to_string(), "v2".to_string()];
724        let best = tracker.find_best_base(&candidates);
725
726        assert_eq!(best, Some("base".to_string())); // base has lowest depth (0)
727    }
728
729    #[test]
730    fn test_get_chain() {
731        let mut tracker = DeltaChainTracker::new();
732
733        tracker.register(
734            "base".to_string(),
735            DeltaMetadata::full("".to_string(), 1000),
736        );
737        tracker.register(
738            "v1".to_string(),
739            DeltaMetadata::delta("base".to_string(), 0, 900, 100, 0.9),
740        );
741        tracker.register(
742            "v2".to_string(),
743            DeltaMetadata::delta("v1".to_string(), 1, 850, 50, 0.85),
744        );
745
746        let chain = tracker.get_chain("v2").unwrap();
747        assert_eq!(chain, vec!["base", "v1", "v2"]);
748    }
749
750    #[test]
751    fn test_delta_encode_decode_simple() {
752        let encoder = DeltaEncoder::new();
753
754        let base = b"Hello, World!";
755        let target = b"Hello, MediaGit World!";
756
757        let (delta, should_use_delta) = encoder.encode(base, target).unwrap();
758
759        if should_use_delta {
760            let reconstructed = encoder.decode(base, &delta).unwrap();
761            assert_eq!(target, &reconstructed[..]);
762        }
763    }
764
765    #[test]
766    fn test_delta_encode_high_similarity() {
767        let encoder = DeltaEncoder::new();
768
769        let base = b"a".repeat(1000);
770        let mut target = base.clone();
771        target[500] = b'b'; // Only one byte different
772
773        let (data, should_use_delta) = encoder.encode(&base, &target).unwrap();
774
775        // For highly similar data, delta encoding should be beneficial
776        if should_use_delta {
777            assert!(
778                data.len() < target.len() / 2,
779                "Delta should be much smaller"
780            );
781            let reconstructed = encoder.decode(&base, &data).unwrap();
782            assert_eq!(target, reconstructed);
783        } else {
784            // If delta encoding wasn't beneficial, should return full data
785            assert_eq!(data, target);
786        }
787    }
788
789    #[test]
790    fn test_delta_encode_low_similarity() {
791        let encoder = DeltaEncoder::new();
792
793        let base: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
794        let target: Vec<u8> = (0..1000).map(|i| ((i + 128) % 256) as u8).collect();
795
796        let (data, should_use_delta) = encoder.encode(&base, &target).unwrap();
797
798        if !should_use_delta {
799            // Low similarity, should return full data
800            assert_eq!(data, target);
801        }
802    }
803
804    #[test]
805    fn test_delta_encode_identical() {
806        let encoder = DeltaEncoder::new();
807
808        let base = b"Hello, World!";
809        let target = b"Hello, World!";
810
811        let (data, should_use_delta) = encoder.encode(base, target).unwrap();
812
813        // For very small identical data, bsdiff may not generate a delta
814        // The system should fall back to returning the full data
815        if should_use_delta {
816            // If delta was used, verify it can be decoded
817            assert!(data.len() < 50, "Delta for identical files should be tiny");
818            let reconstructed = encoder.decode(base, &data).unwrap();
819            assert_eq!(target, &reconstructed[..]);
820        } else {
821            // If delta wasn't used, should return full target data
822            assert_eq!(data, target.to_vec());
823        }
824    }
825
826    #[test]
827    fn test_delta_decode_chain() {
828        let encoder = DeltaEncoder::new();
829
830        let v1 = b"Hello, World!";
831        let v2 = b"Hello, MediaGit World!";
832        let v3 = b"Hello, MediaGit Rust World!";
833
834        let (delta2, should_use_delta2) = encoder.encode(v1, v2).unwrap();
835        let (delta3, should_use_delta3) = encoder.encode(v2, v3).unwrap();
836
837        // Build version chain based on what encoding actually produced
838        let versions = vec![
839            (v1.to_vec(), false),
840            (delta2, should_use_delta2),
841            (delta3, should_use_delta3),
842        ];
843
844        let reconstructed = encoder.decode_chain(&versions).unwrap();
845        assert_eq!(v3, &reconstructed[..]);
846    }
847
848    #[test]
849    fn test_similarity_calculation() {
850        let encoder = DeltaEncoder::new();
851
852        // Identical data
853        let data = b"Hello, World!";
854        assert_eq!(encoder.calculate_similarity(data, data), 1.0);
855
856        // Empty data
857        assert_eq!(encoder.calculate_similarity(b"", b""), 1.0);
858        assert_eq!(encoder.calculate_similarity(b"test", b""), 0.0);
859        assert_eq!(encoder.calculate_similarity(b"", b"test"), 0.0);
860
861        // High similarity
862        let base = b"a".repeat(1000);
863        let mut similar = base.clone();
864        similar[500] = b'b';
865        let sim = encoder.calculate_similarity(&base, &similar);
866        assert!(sim > 0.99, "Similarity should be very high: {}", sim);
867    }
868
869    #[test]
870    fn test_hash_data() {
871        let data1 = b"Hello, World!";
872        let data2 = b"Hello, World!";
873        let data3 = b"Hello, MediaGit!";
874
875        let hash1 = DeltaEncoder::hash_data(data1);
876        let hash2 = DeltaEncoder::hash_data(data2);
877        let hash3 = DeltaEncoder::hash_data(data3);
878
879        assert_eq!(hash1, hash2); // Same data should produce same hash
880        assert_ne!(hash1, hash3); // Different data should produce different hash
881        assert_eq!(hash1.len(), 64); // Blake3 produces 32-byte hash = 64 hex chars
882    }
883
884    #[test]
885    fn test_custom_thresholds() {
886        let encoder = DeltaEncoder::with_thresholds(0.5, 0.05);
887
888        let base = b"Hello, World!";
889        let target = b"Goodbye, World!";
890
891        let (_, should_use_delta) = encoder.encode(base, target).unwrap();
892
893        // With lower thresholds, more likely to use delta
894        // (actual result depends on data characteristics)
895        let _ = should_use_delta; // Just verify it doesn't panic
896    }
897
898    #[test]
899    fn test_large_data() {
900        let encoder = DeltaEncoder::new();
901
902        let base = vec![0x42u8; 1024 * 100]; // 100KB
903        let mut target = base.clone();
904        // Modify 1% of the data
905        for i in (0..target.len()).step_by(100) {
906            target[i] = 0x43;
907        }
908
909        let (data, should_use_delta) = encoder.encode(&base, &target).unwrap();
910
911        // For large data with small changes, delta should be beneficial
912        if should_use_delta {
913            assert!(
914                data.len() < target.len() / 2,
915                "Delta should be much smaller than target"
916            );
917            let reconstructed = encoder.decode(&base, &data).unwrap();
918            assert_eq!(target, reconstructed);
919        } else {
920            // If delta wasn't beneficial, should return full data
921            assert_eq!(data, target);
922        }
923    }
924
925    #[test]
926    fn test_chain_depth_enforcement() {
927        let encoder = DeltaEncoder::with_max_chain_depth(3);
928        assert_eq!(encoder.max_chain_depth(), 3);
929
930        // Use larger data that will definitely benefit from delta encoding
931        let base = vec![0x42u8; 1000];
932        let mut target = base.clone();
933        target[500] = 0x43; // Small change to make delta beneficial
934
935        // Depth 0 - should create delta
936        let (data0, should_use0) = encoder.encode_with_depth(&base, &target, 0).unwrap();
937        if should_use0 {
938            assert!(
939                data0.len() < target.len(),
940                "Delta should be smaller than full data"
941            );
942        }
943
944        // Depth 2 - should still create delta
945        let (_data2, should_use2) = encoder.encode_with_depth(&base, &target, 2).unwrap();
946        // Should use delta as long as we're below max depth
947        assert_eq!(
948            should_use2, should_use0,
949            "Should make same decision at depth 2"
950        );
951
952        // Depth 3 - should NOT create delta (at max depth)
953        let (data3, should_use3) = encoder.encode_with_depth(&base, &target, 3).unwrap();
954        assert!(!should_use3, "Should NOT use delta at max depth");
955        assert_eq!(data3, target, "Should return full data at max depth");
956
957        // Depth 4 - should NOT create delta (exceeds max depth)
958        let (data4, should_use4) = encoder.encode_with_depth(&base, &target, 4).unwrap();
959        assert!(!should_use4, "Should NOT use delta beyond max depth");
960        assert_eq!(data4, target, "Should return full data beyond max depth");
961    }
962
963    #[test]
964    fn test_max_chain_depth_setter() {
965        let mut encoder = DeltaEncoder::new();
966
967        // Default should be MAX_CHAIN_DEPTH (10)
968        assert_eq!(encoder.max_chain_depth(), MAX_CHAIN_DEPTH);
969        assert_eq!(encoder.max_chain_depth(), 10);
970
971        // Set to custom value
972        encoder.set_max_chain_depth(75);
973        assert_eq!(encoder.max_chain_depth(), 75);
974
975        // Set to another value
976        encoder.set_max_chain_depth(25);
977        assert_eq!(encoder.max_chain_depth(), 25);
978    }
979
980    #[test]
981    fn test_chain_depth_with_constructor() {
982        let encoder = DeltaEncoder::with_max_chain_depth(100);
983        assert_eq!(encoder.max_chain_depth(), 100);
984
985        // Verify it actually enforces the limit
986        // Use structured data (repeating text pattern) that bsdiff can compress well
987        let pattern = b"The quick brown fox jumps over the lazy dog. ";
988        let base: Vec<u8> = pattern.iter().cycle().take(100_000).copied().collect();
989        let mut target = base.clone();
990        // Modify a small region
991        for b in &mut target[50_000..50_100] {
992            *b = b'X';
993        }
994
995        // At depth 99, should create delta
996        let (_, should_use99) = encoder.encode_with_depth(&base, &target, 99).unwrap();
997        assert!(should_use99, "Should use delta just below max depth");
998
999        // At depth 100, should NOT create delta
1000        let (_, should_use100) = encoder.encode_with_depth(&base, &target, 100).unwrap();
1001        assert!(!should_use100, "Should NOT use delta at max depth");
1002    }
1003
1004    #[test]
1005    fn test_encode_delegates_to_encode_with_depth() {
1006        let encoder = DeltaEncoder::new();
1007
1008        let base = b"Test data for delta encoding";
1009        let target = b"Test data for delta encoding with changes";
1010
1011        // encode() should delegate to encode_with_depth(0)
1012        let (data1, should1) = encoder.encode(base, target).unwrap();
1013        let (data2, should2) = encoder.encode_with_depth(base, target, 0).unwrap();
1014
1015        assert_eq!(should1, should2, "Both methods should return same decision");
1016        assert_eq!(data1, data2, "Both methods should return same data");
1017    }
1018
1019    #[test]
1020    fn test_new_category_delta_configurations() {
1021        use crate::smart_compressor::{ObjectCategory, ObjectType};
1022
1023        // Test Creative Project configuration
1024        let creative_config = DeltaConfig::for_object_type(ObjectType::AdobePhotoshop);
1025        assert_eq!(
1026            creative_config.similarity_threshold, 0.70,
1027            "Creative projects should have 70% threshold"
1028        );
1029        assert_eq!(
1030            creative_config.max_chain_depth, 10,
1031            "Creative projects should allow depth 10"
1032        );
1033        assert_eq!(
1034            creative_config.min_space_savings, 0.10,
1035            "Creative projects should require 10% savings"
1036        );
1037
1038        // Verify all creative project types use same config
1039        assert_eq!(
1040            ObjectType::AdobePhotoshop.category(),
1041            ObjectCategory::CreativeProject
1042        );
1043        assert_eq!(
1044            ObjectType::Blender.category(),
1045            ObjectCategory::CreativeProject
1046        );
1047        assert_eq!(
1048            ObjectType::DavinciResolve.category(),
1049            ObjectCategory::CreativeProject
1050        );
1051
1052        // Test Office configuration
1053        let office_config = DeltaConfig::for_object_type(ObjectType::WordDocument);
1054        assert_eq!(
1055            office_config.similarity_threshold, 0.75,
1056            "Office docs should have 75% threshold"
1057        );
1058        assert_eq!(
1059            office_config.max_chain_depth, 10,
1060            "Office docs should allow depth 10"
1061        );
1062        assert_eq!(ObjectType::WordDocument.category(), ObjectCategory::Office);
1063
1064        // Test ML Specialized configuration
1065        let ml_config = DeltaConfig::for_object_type(ObjectType::MlCheckpoint);
1066        assert_eq!(
1067            ml_config.similarity_threshold, 0.75,
1068            "ML models should have 75% threshold"
1069        );
1070        assert_eq!(
1071            ml_config.max_chain_depth, 8,
1072            "ML models should allow depth 8"
1073        );
1074        assert_eq!(
1075            ObjectType::MlCheckpoint.category(),
1076            ObjectCategory::MlSpecialized
1077        );
1078        assert_eq!(
1079            ObjectType::MlInference.category(),
1080            ObjectCategory::MlSpecialized
1081        );
1082
1083        // Test Database configuration
1084        let db_config = DeltaConfig::for_object_type(ObjectType::SqliteDatabase);
1085        assert_eq!(
1086            db_config.similarity_threshold, 0.80,
1087            "Databases should have 80% threshold"
1088        );
1089        assert_eq!(
1090            db_config.max_chain_depth, 5,
1091            "Databases should allow depth 5"
1092        );
1093        assert_eq!(
1094            ObjectType::SqliteDatabase.category(),
1095            ObjectCategory::Database
1096        );
1097    }
1098
1099    #[test]
1100    fn test_creative_project_delta_priority() {
1101        use crate::smart_compressor::ObjectType;
1102
1103        // Creative projects have the most aggressive delta configuration
1104        let creative_config = DeltaConfig::for_object_type(ObjectType::AdobePhotoshop);
1105        let image_config = DeltaConfig::for_object_type(ObjectType::Tiff);
1106        let video_config = DeltaConfig::for_object_type(ObjectType::Mp4);
1107
1108        // Creative projects should have lower threshold than images
1109        assert!(
1110            creative_config.similarity_threshold < image_config.similarity_threshold,
1111            "Creative projects (0.70) should have lower threshold than images (0.85)"
1112        );
1113
1114        // Creative projects should allow longer chains than most types
1115        assert!(
1116            creative_config.max_chain_depth >= image_config.max_chain_depth,
1117            "Creative projects should allow equal or longer chains"
1118        );
1119
1120        // Video has highest threshold (metadata changes only)
1121        assert!(
1122            video_config.similarity_threshold > creative_config.similarity_threshold,
1123            "Video (0.95) should have higher threshold than creative projects (0.70)"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_delta_roundtrip_creative_project() {
1129        use crate::smart_compressor::ObjectType;
1130
1131        let encoder = DeltaEncoder::new();
1132
1133        // Use a more realistic test - text-based content like .blend or XML-based creative files
1134        // Simulate creative project data with high similarity
1135        let base_content = b"<CreativeProjectLayer id='1' opacity='100%'>".repeat(100);
1136        let base_data = base_content.clone();
1137
1138        // Modified version (small change - like adjusting opacity in one layer)
1139        let mut modified = base_data.clone();
1140        // Change a few bytes to simulate minor edits
1141        for i in (0..modified.len()).step_by(500) {
1142            if i < modified.len() {
1143                modified[i] = b'X';
1144            }
1145        }
1146
1147        // Calculate similarity
1148        let similarity = encoder.calculate_similarity(&base_data, &modified);
1149
1150        // Get config for creative projects
1151        let config = DeltaConfig::for_object_type(ObjectType::AdobePhotoshop);
1152
1153        // Verify similarity threshold
1154        // Creative projects have 0.70 threshold, should be met with minor changes
1155        assert!(
1156            similarity >= config.similarity_threshold,
1157            "Similarity {:.2} should meet creative project threshold {:.2}",
1158            similarity,
1159            config.similarity_threshold
1160        );
1161
1162        // Test that config is correctly applied
1163        assert_eq!(
1164            config.max_chain_depth, 10,
1165            "Creative projects should allow depth 10"
1166        );
1167        assert_eq!(
1168            config.similarity_threshold, 0.70,
1169            "Creative projects should have 70% threshold"
1170        );
1171
1172        // Verify the category
1173        assert_eq!(
1174            ObjectType::AdobePhotoshop.category(),
1175            crate::smart_compressor::ObjectCategory::CreativeProject,
1176            "AdobePhotoshop should be in CreativeProject category"
1177        );
1178    }
1179}