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