wow_mpq/
compare.rs

1//! MPQ Archive Comparison Functionality
2//!
3//! This module provides functionality to compare two MPQ archives, highlighting
4//! differences in metadata, file lists, and file contents.
5
6use crate::{Archive, FormatVersion, Result};
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10/// Options for archive comparison
11#[derive(Debug, Clone)]
12pub struct CompareOptions {
13    /// Show detailed file-by-file comparison
14    pub detailed: bool,
15    /// Compare actual file contents (slower but thorough)
16    pub content_check: bool,
17    /// Only compare archive metadata
18    pub metadata_only: bool,
19    /// Ignore file order differences
20    pub ignore_order: bool,
21    /// Filter files by pattern
22    pub filter: Option<String>,
23}
24
25impl Default for CompareOptions {
26    fn default() -> Self {
27        Self {
28            detailed: false,
29            content_check: false,
30            metadata_only: false,
31            ignore_order: true,
32            filter: None,
33        }
34    }
35}
36
37/// Result of comparing two archives
38#[derive(Debug, Clone)]
39pub struct ComparisonResult {
40    /// Whether the archives are identical
41    pub identical: bool,
42    /// Archive metadata comparison
43    pub metadata: MetadataComparison,
44    /// File list comparison (None if metadata_only)
45    pub files: Option<FileComparison>,
46    /// Summary of differences
47    pub summary: ComparisonSummary,
48}
49
50/// Archive metadata comparison
51#[derive(Debug, Clone)]
52pub struct MetadataComparison {
53    /// Format version comparison
54    pub format_version: (FormatVersion, FormatVersion),
55    /// Block size comparison
56    pub block_size: (u16, u16),
57    /// File count comparison
58    pub file_count: (usize, usize),
59    /// Archive size comparison
60    pub archive_size: (u64, u64),
61    /// Whether metadata matches
62    pub matches: bool,
63}
64
65/// File list comparison
66#[derive(Debug, Clone)]
67pub struct FileComparison {
68    /// Files only in source archive
69    pub source_only: Vec<String>,
70    /// Files only in target archive
71    pub target_only: Vec<String>,
72    /// Files present in both archives
73    pub common_files: Vec<String>,
74    /// Files with different sizes
75    pub size_differences: Vec<FileSizeDiff>,
76    /// Files with different content (if content_check enabled)
77    pub content_differences: Vec<String>,
78    /// Files with different metadata
79    pub metadata_differences: Vec<FileMetadataDiff>,
80}
81
82/// File size difference
83#[derive(Debug, Clone)]
84pub struct FileSizeDiff {
85    /// File name
86    pub name: String,
87    /// Size in source archive
88    pub source_size: u64,
89    /// Size in target archive
90    pub target_size: u64,
91    /// Compressed size in source archive
92    pub source_compressed: u64,
93    /// Compressed size in target archive
94    pub target_compressed: u64,
95}
96
97/// File metadata difference
98#[derive(Debug, Clone)]
99pub struct FileMetadataDiff {
100    /// File name
101    pub name: String,
102    /// Difference description
103    pub difference: String,
104    /// Source value
105    pub source_value: String,
106    /// Target value
107    pub target_value: String,
108}
109
110/// Summary of comparison results
111#[derive(Debug, Clone)]
112pub struct ComparisonSummary {
113    /// Total files in source
114    pub source_files: usize,
115    /// Total files in target
116    pub target_files: usize,
117    /// Files only in source
118    pub source_only_count: usize,
119    /// Files only in target
120    pub target_only_count: usize,
121    /// Files with differences
122    pub different_files: usize,
123    /// Files that are identical
124    pub identical_files: usize,
125}
126
127/// Compare two MPQ archives
128pub fn compare_archives<P: AsRef<Path>>(
129    source_path: P,
130    target_path: P,
131    detailed: bool,
132    content_check: bool,
133    metadata_only: bool,
134    _ignore_order: bool,
135    filter: Option<String>,
136) -> Result<ComparisonResult> {
137    let source_path = source_path.as_ref();
138    let target_path = target_path.as_ref();
139
140    log::info!(
141        "Comparing archives: {} vs {}",
142        source_path.display(),
143        target_path.display()
144    );
145
146    // Open both archives
147    let mut source_archive = Archive::open(source_path)?;
148    let mut target_archive = Archive::open(target_path)?;
149
150    // Compare metadata
151    let metadata = compare_metadata(&mut source_archive, &mut target_archive)?;
152
153    // Early return if only comparing metadata
154    if metadata_only {
155        return Ok(ComparisonResult {
156            identical: metadata.matches,
157            metadata: metadata.clone(),
158            files: None,
159            summary: ComparisonSummary {
160                source_files: metadata.file_count.0,
161                target_files: metadata.file_count.1,
162                source_only_count: 0,
163                target_only_count: 0,
164                different_files: 0,
165                identical_files: 0,
166            },
167        });
168    }
169
170    // Compare files
171    let files = compare_files(
172        &mut source_archive,
173        &mut target_archive,
174        detailed,
175        content_check,
176        filter,
177    )?;
178
179    // Generate summary
180    let summary = ComparisonSummary {
181        source_files: metadata.file_count.0,
182        target_files: metadata.file_count.1,
183        source_only_count: files.source_only.len(),
184        target_only_count: files.target_only.len(),
185        different_files: files.size_differences.len()
186            + files.content_differences.len()
187            + files.metadata_differences.len(),
188        identical_files: files.common_files.len()
189            - files.size_differences.len()
190            - files.content_differences.len()
191            - files.metadata_differences.len(),
192    };
193
194    // Determine if archives are identical
195    let files_identical = files.source_only.is_empty()
196        && files.target_only.is_empty()
197        && files.size_differences.is_empty()
198        && files.content_differences.is_empty()
199        && files.metadata_differences.is_empty();
200
201    let identical = metadata.matches && files_identical;
202
203    Ok(ComparisonResult {
204        identical,
205        metadata,
206        files: Some(files),
207        summary,
208    })
209}
210
211/// Compare archive metadata
212fn compare_metadata(source: &mut Archive, target: &mut Archive) -> Result<MetadataComparison> {
213    // Get info first (requires mutable borrow)
214    let source_info = source.get_info()?;
215    let target_info = target.get_info()?;
216
217    // Then get headers (immutable borrow after mutable borrow is dropped)
218    let source_header = source.header();
219    let target_header = target.header();
220
221    let format_version = (source_header.format_version, target_header.format_version);
222    let block_size = (source_header.block_size, target_header.block_size);
223    let file_count = (source_info.file_count, target_info.file_count);
224    let archive_size = (source_info.file_size, target_info.file_size);
225
226    let matches = format_version.0 == format_version.1
227        && block_size.0 == block_size.1
228        && file_count.0 == file_count.1;
229
230    Ok(MetadataComparison {
231        format_version,
232        block_size,
233        file_count,
234        archive_size,
235        matches,
236    })
237}
238
239/// Compare files between archives
240fn compare_files(
241    source: &mut Archive,
242    target: &mut Archive,
243    detailed: bool,
244    content_check: bool,
245    filter: Option<String>,
246) -> Result<FileComparison> {
247    // Get file lists
248    let source_files = get_file_list(source, &filter)?;
249    let target_files = get_file_list(target, &filter)?;
250
251    // Convert to HashSets for set operations
252    let source_set: HashSet<_> = source_files.keys().collect();
253    let target_set: HashSet<_> = target_files.keys().collect();
254
255    // Find differences
256    let source_only: Vec<String> = source_set
257        .difference(&target_set)
258        .map(|s| s.to_string())
259        .collect();
260
261    let target_only: Vec<String> = target_set
262        .difference(&source_set)
263        .map(|s| s.to_string())
264        .collect();
265
266    let common_files: Vec<String> = source_set
267        .intersection(&target_set)
268        .map(|s| s.to_string())
269        .collect();
270
271    // Compare common files
272    let mut size_differences = Vec::new();
273    let mut content_differences = Vec::new();
274    let mut metadata_differences = Vec::new();
275
276    for filename in &common_files {
277        let source_entry = &source_files[filename];
278        let target_entry = &target_files[filename];
279
280        // Check size differences
281        if source_entry.size != target_entry.size
282            || source_entry.compressed_size != target_entry.compressed_size
283        {
284            size_differences.push(FileSizeDiff {
285                name: filename.clone(),
286                source_size: source_entry.size,
287                target_size: target_entry.size,
288                source_compressed: source_entry.compressed_size,
289                target_compressed: target_entry.compressed_size,
290            });
291        }
292
293        // Check metadata differences (if detailed)
294        if detailed && source_entry.flags != target_entry.flags {
295            metadata_differences.push(FileMetadataDiff {
296                name: filename.clone(),
297                difference: "Flags".to_string(),
298                source_value: format!("0x{:08x}", source_entry.flags),
299                target_value: format!("0x{:08x}", target_entry.flags),
300            });
301        }
302
303        // Check content differences (if content_check enabled)
304        if content_check {
305            match (source.read_file(filename), target.read_file(filename)) {
306                (Ok(source_data), Ok(target_data)) => {
307                    if source_data != target_data {
308                        content_differences.push(filename.clone());
309                    }
310                }
311                _ => {
312                    // If we can't read either file, consider it a content difference
313                    content_differences.push(filename.clone());
314                }
315            }
316        }
317    }
318
319    Ok(FileComparison {
320        source_only,
321        target_only,
322        common_files,
323        size_differences,
324        content_differences,
325        metadata_differences,
326    })
327}
328
329/// Get file list from archive with optional filtering
330fn get_file_list(
331    archive: &mut Archive,
332    filter: &Option<String>,
333) -> Result<HashMap<String, crate::FileEntry>> {
334    let files = archive
335        .list()
336        .unwrap_or_else(|_| archive.list_all().unwrap_or_default());
337
338    let mut file_map = HashMap::new();
339
340    for file in files {
341        // Apply filter if provided
342        if let Some(pattern) = filter
343            && !simple_pattern_match(&file.name, pattern)
344        {
345            continue;
346        }
347
348        file_map.insert(file.name.clone(), file);
349    }
350
351    Ok(file_map)
352}
353
354/// Simple pattern matching function (supports * wildcard)
355fn simple_pattern_match(text: &str, pattern: &str) -> bool {
356    if pattern == "*" {
357        return true;
358    }
359
360    if pattern.contains('*') {
361        // Convert glob pattern to regex-like matching
362        let parts: Vec<&str> = pattern.split('*').collect();
363        if parts.is_empty() {
364            return true;
365        }
366
367        let mut text_pos = 0;
368        for (i, part) in parts.iter().enumerate() {
369            if part.is_empty() {
370                continue;
371            }
372
373            if i == 0 {
374                // First part must match from the beginning
375                if !text[text_pos..].starts_with(part) {
376                    return false;
377                }
378                text_pos += part.len();
379            } else if i == parts.len() - 1 {
380                // Last part must match at the end
381                return text[text_pos..].ends_with(part);
382            } else {
383                // Middle parts must be found somewhere
384                if let Some(pos) = text[text_pos..].find(part) {
385                    text_pos += pos + part.len();
386                } else {
387                    return false;
388                }
389            }
390        }
391        true
392    } else {
393        // Exact match if no wildcards
394        text == pattern
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_compare_options_default() {
404        let options = CompareOptions::default();
405        assert!(!options.detailed);
406        assert!(!options.content_check);
407        assert!(!options.metadata_only);
408        assert!(options.ignore_order);
409        assert!(options.filter.is_none());
410    }
411
412    #[test]
413    fn test_comparison_summary_new() {
414        let summary = ComparisonSummary {
415            source_files: 100,
416            target_files: 95,
417            source_only_count: 5,
418            target_only_count: 2,
419            different_files: 3,
420            identical_files: 90,
421        };
422
423        assert_eq!(summary.source_files, 100);
424        assert_eq!(summary.target_files, 95);
425        assert_eq!(summary.source_only_count, 5);
426        assert_eq!(summary.target_only_count, 2);
427        assert_eq!(summary.different_files, 3);
428        assert_eq!(summary.identical_files, 90);
429    }
430
431    #[test]
432    fn test_simple_pattern_match() {
433        // Exact matches
434        assert!(simple_pattern_match("test.txt", "test.txt"));
435        assert!(!simple_pattern_match("test.txt", "other.txt"));
436
437        // Wildcard matches
438        assert!(simple_pattern_match("test.txt", "*"));
439        assert!(simple_pattern_match("test.txt", "*.txt"));
440        assert!(simple_pattern_match("test.txt", "test.*"));
441        assert!(simple_pattern_match("test.txt", "*test*"));
442
443        // Complex patterns
444        assert!(simple_pattern_match("folder/test.txt", "*/test.txt"));
445        assert!(simple_pattern_match("folder/test.txt", "folder/*.txt"));
446        assert!(!simple_pattern_match("folder/test.txt", "*.dbc"));
447        assert!(!simple_pattern_match("folder/test.txt", "other/*"));
448    }
449}