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