Skip to main content

exarch_core/creation/
zip.rs

1//! ZIP archive creation.
2//!
3//! This module provides functions for creating ZIP archives with configurable
4//! compression levels and security options.
5
6use crate::ArchiveError;
7use crate::NoopProgress;
8use crate::ProgressCallback;
9use crate::Result;
10use crate::creation::config::CreationConfig;
11use crate::creation::progress::ProgressTracker;
12use crate::creation::report::CreationReport;
13use crate::creation::walker::EntryType;
14use crate::creation::walker::collect_entries;
15use std::fs::File;
16use std::io::Read;
17use std::io::Seek;
18use std::io::Write;
19use std::path::Path;
20use zip::CompressionMethod;
21use zip::ZipWriter;
22use zip::write::SimpleFileOptions;
23
24/// Creates a ZIP archive.
25///
26/// # Examples
27///
28/// ```no_run
29/// use exarch_core::creation::CreationConfig;
30/// use exarch_core::creation::zip::create_zip;
31/// use std::path::Path;
32///
33/// let config = CreationConfig::default();
34/// let report = create_zip(Path::new("output.zip"), &[Path::new("src")], &config)?;
35/// println!("Added {} files", report.files_added);
36/// # Ok::<(), exarch_core::ArchiveError>(())
37/// ```
38///
39/// # Errors
40///
41/// Returns an error if:
42/// - Source path does not exist
43/// - Output file cannot be created
44/// - I/O error during archive creation
45pub fn create_zip<P: AsRef<Path>, Q: AsRef<Path>>(
46    output: P,
47    sources: &[Q],
48    config: &CreationConfig,
49) -> Result<CreationReport> {
50    let file = File::create(output.as_ref())?;
51    create_zip_internal(file, sources, config)
52}
53
54/// Creates a ZIP archive with progress reporting.
55///
56/// This function provides real-time progress updates during archive creation
57/// through callback functions. Useful for displaying progress bars or logging
58/// in interactive applications.
59///
60/// # Parameters
61///
62/// - `output`: Path where the ZIP archive will be created
63/// - `sources`: Slice of source paths to include in the archive
64/// - `config`: Configuration controlling filtering, permissions, compression,
65///   and archiving behavior
66/// - `progress`: Mutable reference to a progress callback implementation
67///
68/// # Progress Callbacks
69///
70/// The `progress` callback receives four types of events:
71///
72/// 1. `on_entry_start`: Called before processing each file/directory
73/// 2. `on_bytes_written`: Called for each chunk of data written (typically
74///    every 64 KB)
75/// 3. `on_entry_complete`: Called after successfully processing an entry
76/// 4. `on_complete`: Called once when the entire archive is finished
77///
78/// Note: Callbacks are invoked frequently during large file processing. For
79/// better performance with very large files, consider batching updates.
80///
81/// # Examples
82///
83/// ```no_run
84/// use exarch_core::ProgressCallback;
85/// use exarch_core::creation::CreationConfig;
86/// use exarch_core::creation::zip::create_zip_with_progress;
87/// use std::path::Path;
88///
89/// struct SimpleProgress;
90///
91/// impl ProgressCallback for SimpleProgress {
92///     fn on_entry_start(&mut self, path: &Path, total: usize, current: usize) {
93///         println!("[{}/{}] Processing: {}", current, total, path.display());
94///     }
95///
96///     fn on_bytes_written(&mut self, bytes: u64) {
97///         // Called frequently - consider rate limiting
98///     }
99///
100///     fn on_entry_complete(&mut self, path: &Path) {
101///         println!("Completed: {}", path.display());
102///     }
103///
104///     fn on_complete(&mut self) {
105///         println!("Archive creation complete!");
106///     }
107/// }
108///
109/// let config = CreationConfig::default();
110/// let mut progress = SimpleProgress;
111/// let report = create_zip_with_progress(
112///     Path::new("output.zip"),
113///     &[Path::new("src")],
114///     &config,
115///     &mut progress,
116/// )?;
117/// # Ok::<(), exarch_core::ArchiveError>(())
118/// ```
119///
120/// # Errors
121///
122/// Returns an error if:
123/// - Source path does not exist
124/// - Output file cannot be created
125/// - I/O error during archive creation
126/// - File metadata cannot be read
127pub fn create_zip_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
128    output: P,
129    sources: &[Q],
130    config: &CreationConfig,
131    progress: &mut dyn ProgressCallback,
132) -> Result<CreationReport> {
133    let file = File::create(output.as_ref())?;
134    create_zip_internal_with_progress(file, sources, config, progress)
135}
136
137/// Internal function that creates ZIP with any writer and progress reporting.
138fn create_zip_internal_with_progress<W: Write + Seek, P: AsRef<Path>>(
139    writer: W,
140    sources: &[P],
141    config: &CreationConfig,
142    progress: &mut dyn ProgressCallback,
143) -> Result<CreationReport> {
144    let mut zip = ZipWriter::new(writer);
145    let mut report = CreationReport::default();
146    let start = std::time::Instant::now();
147
148    // Configure ZIP file options with compression level
149    let options = if config.compression_level == Some(0) {
150        SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
151    } else {
152        let level = config.compression_level.unwrap_or(6);
153        SimpleFileOptions::default()
154            .compression_method(CompressionMethod::Deflated)
155            .compression_level(Some(i64::from(level)))
156    };
157
158    // Single-pass collection of entries (avoids double directory traversal)
159    let entries = collect_entries(sources, config)?;
160    let total_entries = entries.len();
161
162    let mut tracker = ProgressTracker::new(progress, total_entries);
163
164    let mut buffer = vec![0u8; 64 * 1024];
165
166    for entry in &entries {
167        match &entry.entry_type {
168            EntryType::File => {
169                tracker.on_entry_start(&entry.archive_path);
170                add_file_to_zip_with_progress_and_buffer(
171                    &mut zip,
172                    &entry.path,
173                    &entry.archive_path,
174                    config,
175                    &mut report,
176                    &options,
177                    tracker.callback(),
178                    &mut buffer,
179                )?;
180                tracker.on_entry_complete(&entry.archive_path);
181            }
182            EntryType::Directory => {
183                tracker.on_entry_start(&entry.archive_path);
184                // Skip root directory entry (empty path becomes "/" which is invalid)
185                if !entry.archive_path.as_os_str().is_empty() {
186                    let dir_path = format!("{}/", normalize_zip_path(&entry.archive_path)?);
187                    zip.add_directory(&dir_path, options).map_err(|e| {
188                        std::io::Error::other(format!("failed to add directory: {e}"))
189                    })?;
190                    report.directories_added += 1;
191                }
192                tracker.on_entry_complete(&entry.archive_path);
193            }
194            EntryType::Symlink { .. } => {
195                tracker.on_entry_start(&entry.archive_path);
196                if !config.follow_symlinks {
197                    report.files_skipped += 1;
198                    report.add_warning(format!("Skipped symlink: {}", entry.path.display()));
199                }
200                tracker.on_entry_complete(&entry.archive_path);
201            }
202        }
203    }
204
205    // Finish writing ZIP
206    zip.finish()
207        .map_err(|e| std::io::Error::other(format!("failed to finish ZIP archive: {e}")))?;
208
209    report.duration = start.elapsed();
210
211    tracker.on_complete();
212
213    Ok(report)
214}
215
216fn create_zip_internal<W: Write + Seek, P: AsRef<Path>>(
217    writer: W,
218    sources: &[P],
219    config: &CreationConfig,
220) -> Result<CreationReport> {
221    create_zip_internal_with_progress(writer, sources, config, &mut NoopProgress)
222}
223
224/// Adds a single file to the ZIP archive with progress reporting and reusable
225/// buffer.
226#[allow(clippy::too_many_arguments)]
227fn add_file_to_zip_with_progress_and_buffer<W: Write + Seek>(
228    zip: &mut ZipWriter<W>,
229    file_path: &Path,
230    archive_path: &Path,
231    config: &CreationConfig,
232    report: &mut CreationReport,
233    options: &SimpleFileOptions,
234    progress: &mut dyn ProgressCallback,
235    buffer: &mut [u8],
236) -> Result<()> {
237    let mut file = File::open(file_path)?;
238    let metadata = file.metadata()?;
239    let size = metadata.len();
240
241    // Check file size limit
242    if let Some(max_size) = config.max_file_size
243        && size > max_size
244    {
245        report.files_skipped += 1;
246        report.add_warning(format!(
247            "Skipped file (too large): {} ({} bytes)",
248            file_path.display(),
249            size
250        ));
251        return Ok(());
252    }
253
254    // Configure options with permissions if needed
255    let file_options = if config.preserve_permissions {
256        #[cfg(unix)]
257        {
258            use std::os::unix::fs::PermissionsExt;
259            options.unix_permissions(metadata.permissions().mode())
260        }
261        #[cfg(not(unix))]
262        {
263            *options
264        }
265    } else {
266        *options
267    };
268
269    let archive_name = normalize_zip_path(archive_path)?;
270
271    zip.start_file(&archive_name, file_options)
272        .map_err(|e| std::io::Error::other(format!("failed to start file in ZIP: {e}")))?;
273
274    // Copy file contents with progress tracking and reusable buffer
275    let mut bytes_written = 0u64;
276    loop {
277        let bytes_read = file.read(buffer)?;
278        if bytes_read == 0 {
279            break;
280        }
281        zip.write_all(&buffer[..bytes_read])?;
282        bytes_written += bytes_read as u64;
283        progress.on_bytes_written(bytes_read as u64);
284    }
285
286    report.files_added += 1;
287    report.bytes_written += bytes_written;
288
289    Ok(())
290}
291
292/// Normalizes a path for ZIP archive format.
293///
294/// ZIP format requires forward slashes (/) as path separators, regardless
295/// of platform. This function converts platform-specific paths to ZIP format.
296fn normalize_zip_path(path: &Path) -> Result<String> {
297    // Convert to string
298    let path_str = path.to_str().ok_or_else(|| {
299        ArchiveError::Io(std::io::Error::other(format!(
300            "path is not valid UTF-8: {}",
301            path.display()
302        )))
303    })?;
304
305    // Replace backslashes with forward slashes (Windows)
306    #[cfg(windows)]
307    let normalized = path_str.replace('\\', "/");
308
309    #[cfg(not(windows))]
310    let normalized = path_str.to_string();
311
312    Ok(normalized)
313}
314
315/// Format creator for ZIP archives.
316pub struct ZipCreator;
317
318impl crate::formats::traits::FormatCreator for ZipCreator {
319    fn create(
320        &self,
321        output: &std::path::Path,
322        sources: &[&std::path::Path],
323        config: &CreationConfig,
324        progress: &mut dyn ProgressCallback,
325    ) -> crate::Result<crate::creation::CreationReport> {
326        create_zip_with_progress(output, sources, config, progress)
327    }
328
329    fn format_name(&self) -> &'static str {
330        "zip"
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used)] // Allow unwrap in tests for brevity
336mod tests {
337    use super::*;
338    use std::fs;
339    use tempfile::TempDir;
340
341    #[test]
342    fn test_create_zip_single_file() {
343        let temp = TempDir::new().unwrap();
344        let output = temp.path().join("output.zip");
345
346        // Create source file
347        let source_dir = TempDir::new().unwrap();
348        fs::write(source_dir.path().join("test.txt"), "Hello ZIP").unwrap();
349
350        let config = CreationConfig::default()
351            .with_exclude_patterns(vec![])
352            .with_include_hidden(true);
353
354        let report = create_zip(&output, &[source_dir.path().join("test.txt")], &config).unwrap();
355
356        assert_eq!(report.files_added, 1);
357        assert!(report.bytes_written > 0);
358        assert!(output.exists());
359    }
360
361    #[test]
362    fn test_create_zip_directory() {
363        let temp = TempDir::new().unwrap();
364        let output = temp.path().join("output.zip");
365
366        // Create source directory with multiple files
367        let source_dir = TempDir::new().unwrap();
368        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
369        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
370        fs::create_dir(source_dir.path().join("subdir")).unwrap();
371        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
372
373        let config = CreationConfig::default()
374            .with_exclude_patterns(vec![])
375            .with_include_hidden(true);
376
377        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
378
379        // Should have exactly 3 files: file1.txt, file2.txt, subdir/file3.txt
380        assert_eq!(report.files_added, 3);
381        // Should have exactly 1 directory: subdir (root is omitted — empty archive path
382        // is invalid in ZIP)
383        assert_eq!(report.directories_added, 1);
384        assert!(output.exists());
385    }
386
387    #[test]
388    fn test_create_zip_compression() {
389        let temp = TempDir::new().unwrap();
390        let output = temp.path().join("output.zip");
391
392        // Create source file with repetitive content (compresses well)
393        let source_dir = TempDir::new().unwrap();
394        fs::write(source_dir.path().join("test.txt"), "a".repeat(1000)).unwrap();
395
396        let config = CreationConfig::default()
397            .with_exclude_patterns(vec![])
398            .with_compression_level(9)
399            .unwrap();
400
401        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
402
403        assert_eq!(report.files_added, 1);
404        assert!(output.exists());
405
406        // Verify it's a valid ZIP file (basic check)
407        let data = fs::read(&output).unwrap();
408        assert_eq!(&data[0..4], b"PK\x03\x04"); // ZIP local file header magic
409    }
410
411    #[test]
412    fn test_create_zip_compression_levels() {
413        let temp = TempDir::new().unwrap();
414
415        // Create source with repetitive data (compresses well)
416        let source_dir = TempDir::new().unwrap();
417        fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
418
419        // Test different compression levels (1-9 are valid)
420        for level in [1, 6, 9] {
421            let output = temp.path().join(format!("output_{level}.zip"));
422            let config = CreationConfig::default()
423                .with_exclude_patterns(vec![])
424                .with_compression_level(level)
425                .unwrap();
426
427            let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
428            assert_eq!(report.files_added, 1);
429            assert!(output.exists());
430        }
431    }
432
433    #[test]
434    fn test_create_zip_explicit_directories() {
435        let temp = TempDir::new().unwrap();
436        let output = temp.path().join("output.zip");
437
438        // Create source directory structure
439        let source_dir = TempDir::new().unwrap();
440        fs::create_dir(source_dir.path().join("dir1")).unwrap();
441        fs::create_dir(source_dir.path().join("dir1/dir2")).unwrap();
442        fs::write(source_dir.path().join("dir1/dir2/file.txt"), "content").unwrap();
443
444        let config = CreationConfig::default()
445            .with_exclude_patterns(vec![])
446            .with_include_hidden(true);
447
448        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
449
450        assert!(report.directories_added >= 2); // dir1 and dir1/dir2
451        assert!(output.exists());
452
453        // Verify directories have trailing slash by reading archive
454        let file = File::open(&output).unwrap();
455        let mut archive = zip::ZipArchive::new(file).unwrap();
456
457        let mut dir_entries = 0;
458        for i in 0..archive.len() {
459            let entry = archive.by_index(i).unwrap();
460            if entry.is_dir() {
461                dir_entries += 1;
462                assert!(
463                    entry.name().unwrap().ends_with('/'),
464                    "Directory entry should end with /"
465                );
466            }
467        }
468        assert!(dir_entries >= 2, "Expected at least 2 directory entries");
469    }
470
471    #[cfg(unix)]
472    #[test]
473    fn test_create_zip_preserves_permissions() {
474        use std::os::unix::fs::PermissionsExt;
475
476        let temp = TempDir::new().unwrap();
477        let output = temp.path().join("output.zip");
478
479        // Create source file with specific permissions
480        let source_dir = TempDir::new().unwrap();
481        let file_path = source_dir.path().join("test.txt");
482        fs::write(&file_path, "content").unwrap();
483        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
484
485        let config = CreationConfig::default()
486            .with_exclude_patterns(vec![])
487            .with_preserve_permissions(true);
488
489        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
490        assert_eq!(report.files_added, 1);
491
492        // Verify permissions in archive
493        let file = File::open(&output).unwrap();
494        let mut archive = zip::ZipArchive::new(file).unwrap();
495
496        for i in 0..archive.len() {
497            let entry = archive.by_index(i).unwrap();
498            if entry.name().unwrap().contains("test.txt")
499                && let Some(mode) = entry.unix_mode()
500            {
501                assert_eq!(mode & 0o777, 0o755, "Permissions should be preserved");
502            }
503        }
504    }
505
506    #[test]
507    fn test_create_zip_report_statistics() {
508        let temp = TempDir::new().unwrap();
509        let output = temp.path().join("output.zip");
510
511        // Create source directory with known structure
512        let source_dir = TempDir::new().unwrap();
513        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
514        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
515        fs::create_dir(source_dir.path().join("subdir")).unwrap();
516        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
517
518        let config = CreationConfig::default()
519            .with_exclude_patterns(vec![])
520            .with_include_hidden(true);
521
522        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
523
524        assert_eq!(report.files_added, 3);
525        assert!(report.directories_added >= 1);
526        assert_eq!(report.files_skipped, 0);
527        assert!(!report.has_warnings());
528        assert!(report.duration.as_nanos() > 0);
529    }
530
531    #[test]
532    fn test_create_zip_roundtrip() {
533        let temp = TempDir::new().unwrap();
534        let output = temp.path().join("output.zip");
535
536        // Create source directory
537        let source_dir = TempDir::new().unwrap();
538        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
539        fs::create_dir(source_dir.path().join("subdir")).unwrap();
540        fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap();
541
542        let config = CreationConfig::default()
543            .with_exclude_patterns(vec![])
544            .with_include_hidden(true);
545
546        // Create archive
547        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
548        assert!(report.files_added >= 2);
549
550        // Extract and verify using zip crate
551        let file = File::open(&output).unwrap();
552        let mut archive = zip::ZipArchive::new(file).unwrap();
553
554        let extract_dir = TempDir::new().unwrap();
555
556        for i in 0..archive.len() {
557            let mut entry = archive.by_index(i).unwrap();
558            let outpath = extract_dir.path().join(entry.name().unwrap().as_ref());
559
560            if entry.is_dir() {
561                fs::create_dir_all(&outpath).unwrap();
562            } else {
563                if let Some(parent) = outpath.parent() {
564                    fs::create_dir_all(parent).unwrap();
565                }
566                let mut outfile = File::create(&outpath).unwrap();
567                std::io::copy(&mut entry, &mut outfile).unwrap();
568            }
569        }
570
571        // Verify extracted files match originals
572        let extracted1 = fs::read_to_string(extract_dir.path().join("file1.txt")).unwrap();
573        assert_eq!(extracted1, "content1");
574
575        let extracted2 = fs::read_to_string(extract_dir.path().join("subdir/file2.txt")).unwrap();
576        assert_eq!(extracted2, "content2");
577    }
578
579    #[test]
580    fn test_create_zip_forward_slashes() {
581        let temp = TempDir::new().unwrap();
582        let output = temp.path().join("output.zip");
583
584        // Create source directory structure
585        let source_dir = TempDir::new().unwrap();
586        fs::create_dir(source_dir.path().join("dir1")).unwrap();
587        fs::write(source_dir.path().join("dir1/file.txt"), "content").unwrap();
588
589        let config = CreationConfig::default()
590            .with_exclude_patterns(vec![])
591            .with_include_hidden(true);
592
593        create_zip(&output, &[source_dir.path()], &config).unwrap();
594
595        // Verify paths use forward slashes
596        let file = File::open(&output).unwrap();
597        let mut archive = zip::ZipArchive::new(file).unwrap();
598
599        for i in 0..archive.len() {
600            let entry = archive.by_index(i).unwrap();
601            let name = entry.name().unwrap();
602            // ZIP paths should never contain backslashes
603            assert!(
604                !name.contains('\\'),
605                "ZIP path should use forward slashes: {name}"
606            );
607            // Subdirectory paths should use forward slash
608            if name.contains("dir1") && name.contains("file") {
609                assert!(name.contains("dir1/file"), "Expected forward slash in path");
610            }
611        }
612    }
613
614    #[test]
615    fn test_create_zip_source_not_found() {
616        let temp = TempDir::new().unwrap();
617        let output = temp.path().join("output.zip");
618
619        let config = CreationConfig::default();
620        let result = create_zip(&output, &[Path::new("/nonexistent/path")], &config);
621
622        assert!(result.is_err());
623        assert!(matches!(
624            result.unwrap_err(),
625            ArchiveError::SourceNotFound { .. }
626        ));
627    }
628
629    #[test]
630    fn test_normalize_zip_path() {
631        // Basic path
632        let path = Path::new("dir/file.txt");
633        let normalized = normalize_zip_path(path).unwrap();
634        assert_eq!(normalized, "dir/file.txt");
635
636        // Single file
637        let path = Path::new("file.txt");
638        let normalized = normalize_zip_path(path).unwrap();
639        assert_eq!(normalized, "file.txt");
640
641        // Nested directories
642        let path = Path::new("a/b/c/file.txt");
643        let normalized = normalize_zip_path(path).unwrap();
644        assert_eq!(normalized, "a/b/c/file.txt");
645    }
646
647    #[cfg(windows)]
648    #[test]
649    fn test_normalize_zip_path_windows() {
650        // Windows path with backslashes
651        let path = Path::new("dir\\file.txt");
652        let normalized = normalize_zip_path(path).unwrap();
653        assert_eq!(normalized, "dir/file.txt");
654
655        // Nested with backslashes
656        let path = Path::new("a\\b\\c\\file.txt");
657        let normalized = normalize_zip_path(path).unwrap();
658        assert_eq!(normalized, "a/b/c/file.txt");
659    }
660
661    #[test]
662    fn test_create_zip_max_file_size() {
663        let temp = TempDir::new().unwrap();
664        let output = temp.path().join("output.zip");
665
666        // Create files with different sizes
667        let source_dir = TempDir::new().unwrap();
668        fs::write(source_dir.path().join("small.txt"), "tiny").unwrap(); // 4 bytes
669        fs::write(source_dir.path().join("large.txt"), "a".repeat(1000)).unwrap(); // 1000 bytes
670
671        // Set max file size to 100 bytes
672        let config = CreationConfig::default()
673            .with_exclude_patterns(vec![])
674            .with_max_file_size(Some(100));
675
676        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
677
678        // Walker filters out large.txt, so only small.txt is added
679        // No files are skipped at the ZIP level (walker already filtered)
680        assert_eq!(report.files_added, 1);
681        assert_eq!(report.files_skipped, 0);
682    }
683
684    #[cfg(unix)]
685    #[test]
686    fn test_create_zip_skips_symlinks() {
687        let temp = TempDir::new().unwrap();
688        let output = temp.path().join("output.zip");
689
690        // Create source with symlink
691        let source_dir = TempDir::new().unwrap();
692        fs::write(source_dir.path().join("target.txt"), "content").unwrap();
693        std::os::unix::fs::symlink(
694            source_dir.path().join("target.txt"),
695            source_dir.path().join("link.txt"),
696        )
697        .unwrap();
698
699        // Don't follow symlinks (default)
700        let config = CreationConfig::default()
701            .with_exclude_patterns(vec![])
702            .with_include_hidden(true);
703
704        let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
705
706        // Should add target.txt, skip link.txt
707        assert_eq!(report.files_added, 1);
708        assert_eq!(report.files_skipped, 1);
709        assert!(report.has_warnings());
710
711        let warning = &report.warnings[0];
712        assert!(warning.contains("Skipped symlink"));
713    }
714
715    #[test]
716    fn test_create_zip_with_progress_callback() {
717        #[derive(Debug, Default, Clone)]
718        struct TestProgress {
719            entries_started: Vec<String>,
720            entries_completed: Vec<String>,
721            bytes_written: u64,
722            completed: bool,
723        }
724
725        impl ProgressCallback for TestProgress {
726            fn on_entry_start(&mut self, path: &Path, _total: usize, _current: usize) {
727                self.entries_started
728                    .push(path.to_string_lossy().to_string());
729            }
730
731            fn on_bytes_written(&mut self, bytes: u64) {
732                self.bytes_written += bytes;
733            }
734
735            fn on_entry_complete(&mut self, path: &Path) {
736                self.entries_completed
737                    .push(path.to_string_lossy().to_string());
738            }
739
740            fn on_complete(&mut self) {
741                self.completed = true;
742            }
743        }
744
745        let temp = TempDir::new().unwrap();
746        let output = temp.path().join("output.zip");
747
748        // Create source directory with multiple files
749        let source_dir = TempDir::new().unwrap();
750        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
751        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
752        fs::create_dir(source_dir.path().join("subdir")).unwrap();
753        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
754
755        let config = CreationConfig::default()
756            .with_exclude_patterns(vec![])
757            .with_include_hidden(true);
758
759        let mut progress = TestProgress::default();
760
761        let report =
762            create_zip_with_progress(&output, &[source_dir.path()], &config, &mut progress)
763                .unwrap();
764
765        // Verify report
766        assert_eq!(report.files_added, 3);
767        assert!(report.directories_added >= 1);
768
769        // Verify callbacks were invoked
770        assert!(
771            progress.entries_started.len() >= 3,
772            "Expected at least 3 entry starts, got {}",
773            progress.entries_started.len()
774        );
775        assert!(
776            progress.entries_completed.len() >= 3,
777            "Expected at least 3 entry completions, got {}",
778            progress.entries_completed.len()
779        );
780        assert!(
781            progress.bytes_written > 0,
782            "Expected bytes written > 0, got {}",
783            progress.bytes_written
784        );
785        assert!(progress.completed, "Expected on_complete to be called");
786
787        // Verify specific entries
788        let has_file1 = progress
789            .entries_started
790            .iter()
791            .any(|p| p.contains("file1.txt"));
792        let has_file2 = progress
793            .entries_started
794            .iter()
795            .any(|p| p.contains("file2.txt"));
796        let has_file3 = progress
797            .entries_started
798            .iter()
799            .any(|p| p.contains("file3.txt"));
800
801        assert!(has_file1, "Expected file1.txt in progress callbacks");
802        assert!(has_file2, "Expected file2.txt in progress callbacks");
803        assert!(has_file3, "Expected file3.txt in progress callbacks");
804    }
805}