1use crate::ArchiveError;
7use crate::NoopProgress;
8use crate::ProgressCallback;
9use crate::Result;
10use crate::creation::config::CreationConfig;
11use crate::creation::progress::ProgressTracker;
12use crate::creation::report::CreationReport;
13use crate::creation::walker::EntryType;
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
24pub fn create_zip<P: AsRef<Path>, Q: AsRef<Path>>(
46 output: P,
47 sources: &[Q],
48 config: &CreationConfig,
49) -> Result<CreationReport> {
50 let file = File::create(output.as_ref())?;
51 create_zip_internal(file, sources, config)
52}
53
54pub fn create_zip_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
128 output: P,
129 sources: &[Q],
130 config: &CreationConfig,
131 progress: &mut dyn ProgressCallback,
132) -> Result<CreationReport> {
133 let file = File::create(output.as_ref())?;
134 create_zip_internal_with_progress(file, sources, config, progress)
135}
136
137fn create_zip_internal_with_progress<W: Write + Seek, P: AsRef<Path>>(
139 writer: W,
140 sources: &[P],
141 config: &CreationConfig,
142 progress: &mut dyn ProgressCallback,
143) -> Result<CreationReport> {
144 let mut zip = ZipWriter::new(writer);
145 let mut report = CreationReport::default();
146 let start = std::time::Instant::now();
147
148 let options = if config.compression_level == Some(0) {
150 SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
151 } else {
152 let level = config.compression_level.unwrap_or(6);
153 SimpleFileOptions::default()
154 .compression_method(CompressionMethod::Deflated)
155 .compression_level(Some(i64::from(level)))
156 };
157
158 let entries = collect_entries(sources, config)?;
160 let total_entries = entries.len();
161
162 let mut tracker = ProgressTracker::new(progress, total_entries);
163
164 let mut buffer = vec![0u8; 64 * 1024];
165
166 for entry in &entries {
167 match &entry.entry_type {
168 EntryType::File => {
169 tracker.on_entry_start(&entry.archive_path);
170 add_file_to_zip_with_progress_and_buffer(
171 &mut zip,
172 &entry.path,
173 &entry.archive_path,
174 config,
175 &mut report,
176 &options,
177 tracker.callback(),
178 &mut buffer,
179 )?;
180 tracker.on_entry_complete(&entry.archive_path);
181 }
182 EntryType::Directory => {
183 tracker.on_entry_start(&entry.archive_path);
184 if !entry.archive_path.as_os_str().is_empty() {
186 let dir_path = format!("{}/", normalize_zip_path(&entry.archive_path)?);
187 zip.add_directory(&dir_path, options).map_err(|e| {
188 std::io::Error::other(format!("failed to add directory: {e}"))
189 })?;
190 report.directories_added += 1;
191 }
192 tracker.on_entry_complete(&entry.archive_path);
193 }
194 EntryType::Symlink { .. } => {
195 tracker.on_entry_start(&entry.archive_path);
196 if !config.follow_symlinks {
197 report.files_skipped += 1;
198 report.add_warning(format!("Skipped symlink: {}", entry.path.display()));
199 }
200 tracker.on_entry_complete(&entry.archive_path);
201 }
202 }
203 }
204
205 zip.finish()
207 .map_err(|e| std::io::Error::other(format!("failed to finish ZIP archive: {e}")))?;
208
209 report.duration = start.elapsed();
210
211 tracker.on_complete();
212
213 Ok(report)
214}
215
216fn create_zip_internal<W: Write + Seek, P: AsRef<Path>>(
217 writer: W,
218 sources: &[P],
219 config: &CreationConfig,
220) -> Result<CreationReport> {
221 create_zip_internal_with_progress(writer, sources, config, &mut NoopProgress)
222}
223
224#[allow(clippy::too_many_arguments)]
227fn add_file_to_zip_with_progress_and_buffer<W: Write + Seek>(
228 zip: &mut ZipWriter<W>,
229 file_path: &Path,
230 archive_path: &Path,
231 config: &CreationConfig,
232 report: &mut CreationReport,
233 options: &SimpleFileOptions,
234 progress: &mut dyn ProgressCallback,
235 buffer: &mut [u8],
236) -> Result<()> {
237 let mut file = File::open(file_path)?;
238 let metadata = file.metadata()?;
239 let size = metadata.len();
240
241 if let Some(max_size) = config.max_file_size
243 && size > max_size
244 {
245 report.files_skipped += 1;
246 report.add_warning(format!(
247 "Skipped file (too large): {} ({} bytes)",
248 file_path.display(),
249 size
250 ));
251 return Ok(());
252 }
253
254 let file_options = if config.preserve_permissions {
256 #[cfg(unix)]
257 {
258 use std::os::unix::fs::PermissionsExt;
259 options.unix_permissions(metadata.permissions().mode())
260 }
261 #[cfg(not(unix))]
262 {
263 *options
264 }
265 } else {
266 *options
267 };
268
269 let archive_name = normalize_zip_path(archive_path)?;
270
271 zip.start_file(&archive_name, file_options)
272 .map_err(|e| std::io::Error::other(format!("failed to start file in ZIP: {e}")))?;
273
274 let mut bytes_written = 0u64;
276 loop {
277 let bytes_read = file.read(buffer)?;
278 if bytes_read == 0 {
279 break;
280 }
281 zip.write_all(&buffer[..bytes_read])?;
282 bytes_written += bytes_read as u64;
283 progress.on_bytes_written(bytes_read as u64);
284 }
285
286 report.files_added += 1;
287 report.bytes_written += bytes_written;
288
289 Ok(())
290}
291
292fn normalize_zip_path(path: &Path) -> Result<String> {
297 let path_str = path.to_str().ok_or_else(|| {
299 ArchiveError::Io(std::io::Error::other(format!(
300 "path is not valid UTF-8: {}",
301 path.display()
302 )))
303 })?;
304
305 #[cfg(windows)]
307 let normalized = path_str.replace('\\', "/");
308
309 #[cfg(not(windows))]
310 let normalized = path_str.to_string();
311
312 Ok(normalized)
313}
314
315pub struct ZipCreator;
317
318impl crate::formats::traits::FormatCreator for ZipCreator {
319 fn create(
320 &self,
321 output: &std::path::Path,
322 sources: &[&std::path::Path],
323 config: &CreationConfig,
324 progress: &mut dyn ProgressCallback,
325 ) -> crate::Result<crate::creation::CreationReport> {
326 create_zip_with_progress(output, sources, config, progress)
327 }
328
329 fn format_name(&self) -> &'static str {
330 "zip"
331 }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used)] mod tests {
337 use super::*;
338 use std::fs;
339 use tempfile::TempDir;
340
341 #[test]
342 fn test_create_zip_single_file() {
343 let temp = TempDir::new().unwrap();
344 let output = temp.path().join("output.zip");
345
346 let source_dir = TempDir::new().unwrap();
348 fs::write(source_dir.path().join("test.txt"), "Hello ZIP").unwrap();
349
350 let config = CreationConfig::default()
351 .with_exclude_patterns(vec![])
352 .with_include_hidden(true);
353
354 let report = create_zip(&output, &[source_dir.path().join("test.txt")], &config).unwrap();
355
356 assert_eq!(report.files_added, 1);
357 assert!(report.bytes_written > 0);
358 assert!(output.exists());
359 }
360
361 #[test]
362 fn test_create_zip_directory() {
363 let temp = TempDir::new().unwrap();
364 let output = temp.path().join("output.zip");
365
366 let source_dir = TempDir::new().unwrap();
368 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
369 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
370 fs::create_dir(source_dir.path().join("subdir")).unwrap();
371 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
372
373 let config = CreationConfig::default()
374 .with_exclude_patterns(vec![])
375 .with_include_hidden(true);
376
377 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
378
379 assert_eq!(report.files_added, 3);
381 assert_eq!(report.directories_added, 1);
384 assert!(output.exists());
385 }
386
387 #[test]
388 fn test_create_zip_compression() {
389 let temp = TempDir::new().unwrap();
390 let output = temp.path().join("output.zip");
391
392 let source_dir = TempDir::new().unwrap();
394 fs::write(source_dir.path().join("test.txt"), "a".repeat(1000)).unwrap();
395
396 let config = CreationConfig::default()
397 .with_exclude_patterns(vec![])
398 .with_compression_level(9)
399 .unwrap();
400
401 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
402
403 assert_eq!(report.files_added, 1);
404 assert!(output.exists());
405
406 let data = fs::read(&output).unwrap();
408 assert_eq!(&data[0..4], b"PK\x03\x04"); }
410
411 #[test]
412 fn test_create_zip_compression_levels() {
413 let temp = TempDir::new().unwrap();
414
415 let source_dir = TempDir::new().unwrap();
417 fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
418
419 for level in [1, 6, 9] {
421 let output = temp.path().join(format!("output_{level}.zip"));
422 let config = CreationConfig::default()
423 .with_exclude_patterns(vec![])
424 .with_compression_level(level)
425 .unwrap();
426
427 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
428 assert_eq!(report.files_added, 1);
429 assert!(output.exists());
430 }
431 }
432
433 #[test]
434 fn test_create_zip_explicit_directories() {
435 let temp = TempDir::new().unwrap();
436 let output = temp.path().join("output.zip");
437
438 let source_dir = TempDir::new().unwrap();
440 fs::create_dir(source_dir.path().join("dir1")).unwrap();
441 fs::create_dir(source_dir.path().join("dir1/dir2")).unwrap();
442 fs::write(source_dir.path().join("dir1/dir2/file.txt"), "content").unwrap();
443
444 let config = CreationConfig::default()
445 .with_exclude_patterns(vec![])
446 .with_include_hidden(true);
447
448 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
449
450 assert!(report.directories_added >= 2); assert!(output.exists());
452
453 let file = File::open(&output).unwrap();
455 let mut archive = zip::ZipArchive::new(file).unwrap();
456
457 let mut dir_entries = 0;
458 for i in 0..archive.len() {
459 let entry = archive.by_index(i).unwrap();
460 if entry.is_dir() {
461 dir_entries += 1;
462 assert!(
463 entry.name().unwrap().ends_with('/'),
464 "Directory entry should end with /"
465 );
466 }
467 }
468 assert!(dir_entries >= 2, "Expected at least 2 directory entries");
469 }
470
471 #[cfg(unix)]
472 #[test]
473 fn test_create_zip_preserves_permissions() {
474 use std::os::unix::fs::PermissionsExt;
475
476 let temp = TempDir::new().unwrap();
477 let output = temp.path().join("output.zip");
478
479 let source_dir = TempDir::new().unwrap();
481 let file_path = source_dir.path().join("test.txt");
482 fs::write(&file_path, "content").unwrap();
483 fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
484
485 let config = CreationConfig::default()
486 .with_exclude_patterns(vec![])
487 .with_preserve_permissions(true);
488
489 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
490 assert_eq!(report.files_added, 1);
491
492 let file = File::open(&output).unwrap();
494 let mut archive = zip::ZipArchive::new(file).unwrap();
495
496 for i in 0..archive.len() {
497 let entry = archive.by_index(i).unwrap();
498 if entry.name().unwrap().contains("test.txt")
499 && let Some(mode) = entry.unix_mode()
500 {
501 assert_eq!(mode & 0o777, 0o755, "Permissions should be preserved");
502 }
503 }
504 }
505
506 #[test]
507 fn test_create_zip_report_statistics() {
508 let temp = TempDir::new().unwrap();
509 let output = temp.path().join("output.zip");
510
511 let source_dir = TempDir::new().unwrap();
513 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
514 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
515 fs::create_dir(source_dir.path().join("subdir")).unwrap();
516 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
517
518 let config = CreationConfig::default()
519 .with_exclude_patterns(vec![])
520 .with_include_hidden(true);
521
522 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
523
524 assert_eq!(report.files_added, 3);
525 assert!(report.directories_added >= 1);
526 assert_eq!(report.files_skipped, 0);
527 assert!(!report.has_warnings());
528 assert!(report.duration.as_nanos() > 0);
529 }
530
531 #[test]
532 fn test_create_zip_roundtrip() {
533 let temp = TempDir::new().unwrap();
534 let output = temp.path().join("output.zip");
535
536 let source_dir = TempDir::new().unwrap();
538 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
539 fs::create_dir(source_dir.path().join("subdir")).unwrap();
540 fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap();
541
542 let config = CreationConfig::default()
543 .with_exclude_patterns(vec![])
544 .with_include_hidden(true);
545
546 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
548 assert!(report.files_added >= 2);
549
550 let file = File::open(&output).unwrap();
552 let mut archive = zip::ZipArchive::new(file).unwrap();
553
554 let extract_dir = TempDir::new().unwrap();
555
556 for i in 0..archive.len() {
557 let mut entry = archive.by_index(i).unwrap();
558 let outpath = extract_dir.path().join(entry.name().unwrap().as_ref());
559
560 if entry.is_dir() {
561 fs::create_dir_all(&outpath).unwrap();
562 } else {
563 if let Some(parent) = outpath.parent() {
564 fs::create_dir_all(parent).unwrap();
565 }
566 let mut outfile = File::create(&outpath).unwrap();
567 std::io::copy(&mut entry, &mut outfile).unwrap();
568 }
569 }
570
571 let extracted1 = fs::read_to_string(extract_dir.path().join("file1.txt")).unwrap();
573 assert_eq!(extracted1, "content1");
574
575 let extracted2 = fs::read_to_string(extract_dir.path().join("subdir/file2.txt")).unwrap();
576 assert_eq!(extracted2, "content2");
577 }
578
579 #[test]
580 fn test_create_zip_forward_slashes() {
581 let temp = TempDir::new().unwrap();
582 let output = temp.path().join("output.zip");
583
584 let source_dir = TempDir::new().unwrap();
586 fs::create_dir(source_dir.path().join("dir1")).unwrap();
587 fs::write(source_dir.path().join("dir1/file.txt"), "content").unwrap();
588
589 let config = CreationConfig::default()
590 .with_exclude_patterns(vec![])
591 .with_include_hidden(true);
592
593 create_zip(&output, &[source_dir.path()], &config).unwrap();
594
595 let file = File::open(&output).unwrap();
597 let mut archive = zip::ZipArchive::new(file).unwrap();
598
599 for i in 0..archive.len() {
600 let entry = archive.by_index(i).unwrap();
601 let name = entry.name().unwrap();
602 assert!(
604 !name.contains('\\'),
605 "ZIP path should use forward slashes: {name}"
606 );
607 if name.contains("dir1") && name.contains("file") {
609 assert!(name.contains("dir1/file"), "Expected forward slash in path");
610 }
611 }
612 }
613
614 #[test]
615 fn test_create_zip_source_not_found() {
616 let temp = TempDir::new().unwrap();
617 let output = temp.path().join("output.zip");
618
619 let config = CreationConfig::default();
620 let result = create_zip(&output, &[Path::new("/nonexistent/path")], &config);
621
622 assert!(result.is_err());
623 assert!(matches!(
624 result.unwrap_err(),
625 ArchiveError::SourceNotFound { .. }
626 ));
627 }
628
629 #[test]
630 fn test_normalize_zip_path() {
631 let path = Path::new("dir/file.txt");
633 let normalized = normalize_zip_path(path).unwrap();
634 assert_eq!(normalized, "dir/file.txt");
635
636 let path = Path::new("file.txt");
638 let normalized = normalize_zip_path(path).unwrap();
639 assert_eq!(normalized, "file.txt");
640
641 let path = Path::new("a/b/c/file.txt");
643 let normalized = normalize_zip_path(path).unwrap();
644 assert_eq!(normalized, "a/b/c/file.txt");
645 }
646
647 #[cfg(windows)]
648 #[test]
649 fn test_normalize_zip_path_windows() {
650 let path = Path::new("dir\\file.txt");
652 let normalized = normalize_zip_path(path).unwrap();
653 assert_eq!(normalized, "dir/file.txt");
654
655 let path = Path::new("a\\b\\c\\file.txt");
657 let normalized = normalize_zip_path(path).unwrap();
658 assert_eq!(normalized, "a/b/c/file.txt");
659 }
660
661 #[test]
662 fn test_create_zip_max_file_size() {
663 let temp = TempDir::new().unwrap();
664 let output = temp.path().join("output.zip");
665
666 let source_dir = TempDir::new().unwrap();
668 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()
673 .with_exclude_patterns(vec![])
674 .with_max_file_size(Some(100));
675
676 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
677
678 assert_eq!(report.files_added, 1);
681 assert_eq!(report.files_skipped, 0);
682 }
683
684 #[cfg(unix)]
685 #[test]
686 fn test_create_zip_skips_symlinks() {
687 let temp = TempDir::new().unwrap();
688 let output = temp.path().join("output.zip");
689
690 let source_dir = TempDir::new().unwrap();
692 fs::write(source_dir.path().join("target.txt"), "content").unwrap();
693 std::os::unix::fs::symlink(
694 source_dir.path().join("target.txt"),
695 source_dir.path().join("link.txt"),
696 )
697 .unwrap();
698
699 let config = CreationConfig::default()
701 .with_exclude_patterns(vec![])
702 .with_include_hidden(true);
703
704 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
705
706 assert_eq!(report.files_added, 1);
708 assert_eq!(report.files_skipped, 1);
709 assert!(report.has_warnings());
710
711 let warning = &report.warnings[0];
712 assert!(warning.contains("Skipped symlink"));
713 }
714
715 #[test]
716 fn test_create_zip_with_progress_callback() {
717 #[derive(Debug, Default, Clone)]
718 struct TestProgress {
719 entries_started: Vec<String>,
720 entries_completed: Vec<String>,
721 bytes_written: u64,
722 completed: bool,
723 }
724
725 impl ProgressCallback for TestProgress {
726 fn on_entry_start(&mut self, path: &Path, _total: usize, _current: usize) {
727 self.entries_started
728 .push(path.to_string_lossy().to_string());
729 }
730
731 fn on_bytes_written(&mut self, bytes: u64) {
732 self.bytes_written += bytes;
733 }
734
735 fn on_entry_complete(&mut self, path: &Path) {
736 self.entries_completed
737 .push(path.to_string_lossy().to_string());
738 }
739
740 fn on_complete(&mut self) {
741 self.completed = true;
742 }
743 }
744
745 let temp = TempDir::new().unwrap();
746 let output = temp.path().join("output.zip");
747
748 let source_dir = TempDir::new().unwrap();
750 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
751 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
752 fs::create_dir(source_dir.path().join("subdir")).unwrap();
753 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
754
755 let config = CreationConfig::default()
756 .with_exclude_patterns(vec![])
757 .with_include_hidden(true);
758
759 let mut progress = TestProgress::default();
760
761 let report =
762 create_zip_with_progress(&output, &[source_dir.path()], &config, &mut progress)
763 .unwrap();
764
765 assert_eq!(report.files_added, 3);
767 assert!(report.directories_added >= 1);
768
769 assert!(
771 progress.entries_started.len() >= 3,
772 "Expected at least 3 entry starts, got {}",
773 progress.entries_started.len()
774 );
775 assert!(
776 progress.entries_completed.len() >= 3,
777 "Expected at least 3 entry completions, got {}",
778 progress.entries_completed.len()
779 );
780 assert!(
781 progress.bytes_written > 0,
782 "Expected bytes written > 0, got {}",
783 progress.bytes_written
784 );
785 assert!(progress.completed, "Expected on_complete to be called");
786
787 let has_file1 = progress
789 .entries_started
790 .iter()
791 .any(|p| p.contains("file1.txt"));
792 let has_file2 = progress
793 .entries_started
794 .iter()
795 .any(|p| p.contains("file2.txt"));
796 let has_file3 = progress
797 .entries_started
798 .iter()
799 .any(|p| p.contains("file3.txt"));
800
801 assert!(has_file1, "Expected file1.txt in progress callbacks");
802 assert!(has_file2, "Expected file2.txt in progress callbacks");
803 assert!(has_file3, "Expected file3.txt in progress callbacks");
804 }
805}