1use 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#[allow(dead_code)] pub 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#[allow(dead_code)] pub 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
139fn 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 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 let entries = collect_entries(sources, config)?;
162 let total_entries = entries.len();
163
164 let mut buffer = vec![0u8; 64 * 1024]; 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 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 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
219fn 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 let options = if config.compression_level == Some(0) {
233 SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
234 } else {
235 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 if !path.exists() {
247 return Err(ExtractionError::SourceNotFound {
248 path: path.to_path_buf(),
249 });
250 }
251
252 if path.is_dir() {
254 add_directory_to_zip(&mut zip, path, config, &mut report, &options)?;
255 } else {
256 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 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
272fn 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 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 report.files_skipped += 1;
308 report.add_warning(format!("Skipped symlink: {}", entry.path.display()));
309 }
310 }
311 }
312 }
313
314 Ok(())
315}
316
317fn 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 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 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 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 let mut buffer = vec![0u8; 64 * 1024]; 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#[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 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 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 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#[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]; 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
478fn normalize_zip_path(path: &Path) -> Result<String> {
483 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 #[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)] mod 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 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 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 assert_eq!(report.files_added, 3);
548 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 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 let data = fs::read(&output).unwrap();
573 assert_eq!(&data[0..4], b"PK\x03\x04"); }
575
576 #[test]
577 fn test_create_zip_compression_levels() {
578 let temp = TempDir::new().unwrap();
579
580 let source_dir = TempDir::new().unwrap();
582 fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
583
584 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 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); assert!(output.exists());
616
617 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 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 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 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 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 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
712 assert!(report.files_added >= 2);
713
714 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 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 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 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 assert!(
768 !name.contains('\\'),
769 "ZIP path should use forward slashes: {name}"
770 );
771 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 let path = Path::new("dir/file.txt");
797 let normalized = normalize_zip_path(path).unwrap();
798 assert_eq!(normalized, "dir/file.txt");
799
800 let path = Path::new("file.txt");
802 let normalized = normalize_zip_path(path).unwrap();
803 assert_eq!(normalized, "file.txt");
804
805 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 let path = Path::new("dir\\file.txt");
816 let normalized = normalize_zip_path(path).unwrap();
817 assert_eq!(normalized, "dir/file.txt");
818
819 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 let source_dir = TempDir::new().unwrap();
832 fs::write(source_dir.path().join("small.txt"), "tiny").unwrap(); fs::write(source_dir.path().join("large.txt"), "a".repeat(1000)).unwrap(); 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 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 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 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 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 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 assert_eq!(report.files_added, 3);
931 assert!(report.directories_added >= 1);
932
933 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 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}