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