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