spring_batch_rs/tasklet/zip.rs
1//! # Zip File Tasklet
2//!
3//! This module provides a tasklet for creating ZIP archives from files and directories.
4//! It's designed to be similar to Spring Batch's file compression capabilities.
5//!
6//! ## Features
7//!
8//! - Compress single files or entire directories
9//! - Configurable compression level
10//! - Support for filtering files to include/exclude
11//! - Proper error handling and logging
12//! - Builder pattern for easy configuration
13//!
14//! ## Examples
15//!
16//! ### Basic ZIP Creation
17//!
18//! ```rust
19//! use spring_batch_rs::core::step::{StepBuilder, StepExecution, Step};
20//! use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
21//! use std::path::Path;
22//! use std::fs;
23//! use std::env::temp_dir;
24//!
25//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
26//! // Create test data directory and file
27//! let temp_data_dir = temp_dir().join("test_data_zip");
28//! fs::create_dir_all(&temp_data_dir).unwrap();
29//! fs::write(temp_data_dir.join("test.txt"), "test content").unwrap();
30//!
31//! let archive_path = temp_dir().join("archive_test.zip");
32//!
33//! let zip_tasklet = ZipTaskletBuilder::new()
34//! .source_path(&temp_data_dir)
35//! .target_path(&archive_path)
36//! .compression_level(6)
37//! .build()?;
38//!
39//! let step = StepBuilder::new("zip-files")
40//! .tasklet(&zip_tasklet)
41//! .build();
42//!
43//! let mut step_execution = StepExecution::new("zip-files");
44//! step.execute(&mut step_execution)?;
45//!
46//! // Cleanup test files
47//! fs::remove_file(&archive_path).ok();
48//! fs::remove_dir_all(&temp_data_dir).ok();
49//! # Ok(())
50//! # }
51//! ```
52//!
53//! ### ZIP with File Filtering
54//!
55//! ```rust
56//! use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
57//!
58//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
59//! let zip_tasklet = ZipTaskletBuilder::new()
60//! .source_path("./logs")
61//! .target_path("./logs_archive.zip")
62//! .include_pattern("*.log")
63//! .exclude_pattern("*.tmp")
64//! .build()?;
65//! # Ok(())
66//! # }
67//! ```
68
69use crate::{
70 core::step::{RepeatStatus, StepExecution, Tasklet},
71 BatchError,
72};
73use log::{debug, info, warn};
74use std::{
75 fs::{self, File},
76 io::{self, Write},
77 path::{Path, PathBuf},
78};
79use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
80
81/// A tasklet for creating ZIP archives from files and directories.
82///
83/// This tasklet provides functionality similar to Spring Batch's file compression
84/// capabilities, allowing you to compress files and directories into ZIP archives
85/// as part of a batch processing step.
86///
87/// # Examples
88///
89/// ```rust
90/// use spring_batch_rs::core::step::{StepExecution, RepeatStatus, Tasklet};
91/// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
92/// use spring_batch_rs::BatchError;
93/// use std::path::Path;
94/// use std::fs;
95/// use std::env::temp_dir;
96///
97/// # fn example() -> Result<(), BatchError> {
98/// // Create test data directory and file
99/// let temp_source_dir = temp_dir().join("test_source");
100/// fs::create_dir_all(&temp_source_dir).unwrap();
101/// fs::write(temp_source_dir.join("test.txt"), "test content").unwrap();
102///
103/// let archive_path = temp_dir().join("test_archive.zip");
104///
105/// let tasklet = ZipTaskletBuilder::new()
106/// .source_path(&temp_source_dir)
107/// .target_path(&archive_path)
108/// .build()?;
109///
110/// let step_execution = StepExecution::new("zip-step");
111/// let result = tasklet.execute(&step_execution)?;
112/// assert_eq!(result, RepeatStatus::Finished);
113///
114/// // Cleanup test files
115/// fs::remove_file(&archive_path).ok();
116/// fs::remove_dir_all(&temp_source_dir).ok();
117/// # Ok(())
118/// # }
119/// ```
120pub struct ZipTasklet {
121 /// Source path to compress (file or directory)
122 source_path: PathBuf,
123 /// Target ZIP file path
124 target_path: PathBuf,
125 /// Compression level (0-9, where 9 is maximum compression)
126 compression_level: i32,
127 /// Pattern for files to include (glob pattern)
128 include_pattern: Option<String>,
129 /// Pattern for files to exclude (glob pattern)
130 exclude_pattern: Option<String>,
131 /// Whether to preserve directory structure
132 preserve_structure: bool,
133}
134
135impl ZipTasklet {
136 /// Creates a new ZipTasklet with default settings.
137 ///
138 /// All parameters must be set using the builder methods before use.
139 /// Use the builder pattern for a more convenient API.
140 ///
141 /// # Returns
142 ///
143 /// A new ZipTasklet instance with default settings.
144 ///
145 /// # Examples
146 ///
147 /// ```rust
148 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
149 /// use std::path::Path;
150 /// use std::fs;
151 /// use std::env::temp_dir;
152 ///
153 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
154 /// // Create test data directory
155 /// let temp_data_dir = temp_dir().join("test_data_new");
156 /// fs::create_dir_all(&temp_data_dir).unwrap();
157 /// fs::write(temp_data_dir.join("test.txt"), "test content").unwrap();
158 ///
159 /// let backup_path = temp_dir().join("backup.zip");
160 ///
161 /// let tasklet = ZipTaskletBuilder::new()
162 /// .source_path(&temp_data_dir)
163 /// .target_path(&backup_path)
164 /// .build()?;
165 ///
166 /// // Cleanup test files
167 /// fs::remove_dir_all(&temp_data_dir).ok();
168 /// # Ok(())
169 /// # }
170 /// ```
171 fn new() -> Self {
172 Self {
173 source_path: PathBuf::new(),
174 target_path: PathBuf::new(),
175 compression_level: 6, // Default compression level
176 include_pattern: None,
177 exclude_pattern: None,
178 preserve_structure: true,
179 }
180 }
181
182 /// Sets the source path to compress.
183 ///
184 /// This is a required parameter that must be set before using the tasklet.
185 ///
186 /// # Parameters
187 /// - `path`: Path to the file or directory to compress
188 ///
189 /// # Returns
190 /// The updated ZipTasklet instance.
191 ///
192 /// # Examples
193 ///
194 /// ```ignore
195 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
196 /// use std::path::Path;
197 /// use std::fs;
198 /// use std::env::temp_dir;
199 ///
200 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
201 /// let temp_data_dir = temp_dir().join("test_data");
202 /// fs::create_dir_all(&temp_data_dir).unwrap();
203 ///
204 /// let tasklet = ZipTasklet::new()
205 /// .source_path(&temp_data_dir);
206 ///
207 /// fs::remove_dir_all(&temp_data_dir).ok();
208 /// # Ok(())
209 /// # }
210 /// ```
211 pub fn source_path<P: AsRef<Path>>(mut self, path: P) -> Self {
212 self.source_path = path.as_ref().to_path_buf();
213 self
214 }
215
216 /// Sets the target ZIP file path.
217 ///
218 /// This is a required parameter that must be set before using the tasklet.
219 ///
220 /// # Parameters
221 /// - `path`: Path where the ZIP file will be created
222 ///
223 /// # Returns
224 /// The updated ZipTasklet instance, or an error if path validation fails.
225 ///
226 /// # Errors
227 /// - Returns error if source path doesn't exist (when source_path has been set)
228 /// - Returns error if target directory cannot be created
229 ///
230 /// # Examples
231 ///
232 /// ```ignore
233 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
234 /// use std::path::Path;
235 /// use std::fs;
236 /// use std::env::temp_dir;
237 ///
238 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
239 /// let temp_data_dir = temp_dir().join("test_data");
240 /// fs::create_dir_all(&temp_data_dir).unwrap();
241 /// let backup_path = temp_dir().join("backup.zip");
242 ///
243 /// let tasklet = ZipTasklet::new()
244 /// .source_path(&temp_data_dir)
245 /// .target_path(&backup_path)?;
246 ///
247 /// fs::remove_dir_all(&temp_data_dir).ok();
248 /// # Ok(())
249 /// # }
250 /// ```
251 pub fn target_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self, BatchError> {
252 let target = path.as_ref().to_path_buf();
253
254 // Validate source path exists if it has been set
255 if !self.source_path.as_os_str().is_empty() && !self.source_path.exists() {
256 return Err(BatchError::Io(io::Error::new(
257 io::ErrorKind::NotFound,
258 format!("Source path does not exist: {}", self.source_path.display()),
259 )));
260 }
261
262 // Ensure target directory exists
263 if let Some(parent) = target.parent() {
264 if !parent.exists() {
265 fs::create_dir_all(parent).map_err(|e| {
266 BatchError::Io(io::Error::new(
267 io::ErrorKind::PermissionDenied,
268 format!("Cannot create target directory {}: {}", parent.display(), e),
269 ))
270 })?;
271 }
272 }
273
274 self.target_path = target;
275 Ok(self)
276 }
277
278 /// Sets the compression level for the ZIP archive.
279 ///
280 /// # Parameters
281 /// - `level`: Compression level (0-9, where 0 is no compression and 9 is maximum)
282 ///
283 /// # Returns
284 /// The updated ZipTasklet instance.
285 ///
286 /// # Examples
287 ///
288 /// ```ignore
289 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
290 ///
291 /// let tasklet = ZipTasklet::new()
292 /// .compression_level(9); // Maximum compression
293 /// ```
294 pub fn compression_level(mut self, level: i32) -> Self {
295 self.compression_level = level.clamp(0, 9);
296 self
297 }
298
299 /// Sets a pattern for files to include in the archive.
300 ///
301 /// # Parameters
302 /// - `pattern`: Glob pattern for files to include (e.g., "*.txt", "**/*.log")
303 ///
304 /// # Returns
305 /// The updated ZipTasklet instance.
306 ///
307 /// # Examples
308 ///
309 /// ```ignore
310 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
311 ///
312 /// let tasklet = ZipTasklet::new()
313 /// .include_pattern("*.log");
314 /// ```
315 pub fn include_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
316 self.include_pattern = Some(pattern.into());
317 self
318 }
319
320 /// Sets a pattern for files to exclude from the archive.
321 ///
322 /// # Parameters
323 /// - `pattern`: Glob pattern for files to exclude (e.g., "*.tmp", "**/target/**")
324 ///
325 /// # Returns
326 /// The updated ZipTasklet instance.
327 ///
328 /// # Examples
329 ///
330 /// ```ignore
331 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
332 ///
333 /// let tasklet = ZipTasklet::new()
334 /// .exclude_pattern("target/**");
335 /// ```
336 pub fn exclude_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
337 self.exclude_pattern = Some(pattern.into());
338 self
339 }
340
341 /// Sets whether to preserve directory structure in the archive.
342 ///
343 /// # Parameters
344 /// - `preserve`: If true, maintains directory structure; if false, flattens all files
345 ///
346 /// # Returns
347 /// The updated ZipTasklet instance.
348 ///
349 /// # Examples
350 ///
351 /// ```ignore
352 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
353 ///
354 /// let tasklet = ZipTasklet::new()
355 /// .preserve_structure(false); // Flatten all files
356 /// ```
357 pub fn preserve_structure(mut self, preserve: bool) -> Self {
358 self.preserve_structure = preserve;
359 self
360 }
361
362 /// Sets the compression level for the ZIP archive.
363 ///
364 /// # Parameters
365 /// - `level`: Compression level (0-9, where 0 is no compression and 9 is maximum)
366 ///
367 /// # Examples
368 ///
369 /// ```ignore
370 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
371 /// use std::path::Path;
372 /// use std::fs;
373 /// use std::env::temp_dir;
374 ///
375 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
376 /// // Create test data directory
377 /// let temp_data_dir = temp_dir().join("test_data_compression");
378 /// fs::create_dir_all(&temp_data_dir).unwrap();
379 /// fs::write(temp_data_dir.join("test.txt"), "test content").unwrap();
380 ///
381 /// let backup_path = temp_dir().join("backup_compression.zip");
382 ///
383 /// let mut tasklet = ZipTasklet::new()
384 /// .source_path(&temp_data_dir)
385 /// .target_path(&backup_path)?;
386 /// tasklet.set_compression_level(9); // Maximum compression
387 ///
388 /// // Cleanup test files
389 /// fs::remove_dir_all(&temp_data_dir).ok();
390 /// # Ok(())
391 /// # }
392 /// ```
393 pub fn set_compression_level(&mut self, level: i32) {
394 self.compression_level = level.clamp(0, 9);
395 }
396
397 /// Checks if a file should be included based on include/exclude patterns.
398 ///
399 /// # Parameters
400 /// - `path`: Path to check
401 ///
402 /// # Returns
403 /// - `true` if the file should be included
404 /// - `false` if the file should be excluded
405 fn should_include_file(&self, path: &Path) -> bool {
406 let path_str = path.to_string_lossy();
407
408 // Check exclude pattern first
409 if let Some(ref exclude) = self.exclude_pattern {
410 if self.matches_pattern(&path_str, exclude) {
411 debug!("Excluding file due to exclude pattern: {}", path.display());
412 return false;
413 }
414 }
415
416 // Check include pattern
417 if let Some(ref include) = self.include_pattern {
418 if !self.matches_pattern(&path_str, include) {
419 debug!("Excluding file due to include pattern: {}", path.display());
420 return false;
421 }
422 }
423
424 true
425 }
426
427 /// Simple pattern matching for file paths.
428 ///
429 /// This is a basic implementation that supports:
430 /// - `*` for any characters within a filename
431 /// - `**` for any characters including directory separators
432 ///
433 /// # Parameters
434 /// - `path`: Path to match against
435 /// - `pattern`: Pattern to match
436 ///
437 /// # Returns
438 /// - `true` if the path matches the pattern
439 /// - `false` otherwise
440 fn matches_pattern(&self, path: &str, pattern: &str) -> bool {
441 // Simple glob-like pattern matching
442 if pattern == "*" || pattern == "**" {
443 return true;
444 }
445
446 if pattern.contains("**") {
447 // Handle recursive patterns like "**/*.txt"
448 let parts: Vec<&str> = pattern.split("**").collect();
449 if parts.len() == 2 {
450 let prefix = parts[0];
451 let suffix = parts[1];
452
453 // For "**/*.txt", prefix is "" and suffix is "/*.txt"
454 // We need to check if the path ends with the suffix pattern
455 if prefix.is_empty() && suffix.starts_with('/') {
456 // Remove the leading '/' from suffix and check if path ends with it
457 let suffix_pattern = &suffix[1..];
458 return self.matches_simple_pattern(path, suffix_pattern)
459 || path
460 .split('/')
461 .any(|segment| self.matches_simple_pattern(segment, suffix_pattern));
462 } else {
463 return path.starts_with(prefix) && path.ends_with(suffix);
464 }
465 }
466 }
467
468 self.matches_simple_pattern(path, pattern)
469 }
470
471 fn matches_simple_pattern(&self, path: &str, pattern: &str) -> bool {
472 if pattern.contains('*') {
473 // Handle single-level wildcards
474 let parts: Vec<&str> = pattern.split('*').collect();
475 if parts.len() == 2 {
476 let prefix = parts[0];
477 let suffix = parts[1];
478 return path.starts_with(prefix) && path.ends_with(suffix);
479 }
480 }
481
482 // Exact match
483 path == pattern
484 }
485
486 /// Compresses a single file into the ZIP archive.
487 ///
488 /// This method handles compression level 0 specially by using the `Stored` compression method
489 /// (no compression) instead of `Deflated` with level 0, which is the correct approach for
490 /// the ZIP format specification.
491 ///
492 /// # Parameters
493 /// - `zip_writer`: ZIP writer instance
494 /// - `file_path`: Path to the file to compress
495 /// - `archive_path`: Path within the archive
496 ///
497 /// # Returns
498 /// - `Ok(())`: File successfully compressed
499 /// - `Err(BatchError)`: Error during compression
500 fn compress_file(
501 &self,
502 zip_writer: &mut ZipWriter<File>,
503 file_path: &Path,
504 archive_path: &str,
505 ) -> Result<(), BatchError> {
506 debug!(
507 "Compressing file: {} -> {}",
508 file_path.display(),
509 archive_path
510 );
511
512 let options = if self.compression_level == 0 {
513 // Use stored method (no compression) for level 0 - this is the correct approach
514 // for ZIP format as Deflated with level 0 can cause issues with some ZIP readers
515 SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
516 } else {
517 // Use deflated method with specified compression level (1-9)
518 SimpleFileOptions::default()
519 .compression_method(CompressionMethod::Deflated)
520 .compression_level(Some(self.compression_level as i64))
521 };
522
523 zip_writer
524 .start_file(archive_path, options)
525 .map_err(|e| BatchError::Io(io::Error::other(e)))?;
526
527 let file_content = fs::read(file_path).map_err(BatchError::Io)?;
528 zip_writer
529 .write_all(&file_content)
530 .map_err(BatchError::Io)?;
531
532 info!("Successfully compressed: {}", archive_path);
533 Ok(())
534 }
535
536 /// Recursively compresses a directory into the ZIP archive.
537 ///
538 /// # Parameters
539 /// - `zip_writer`: ZIP writer instance
540 /// - `dir_path`: Path to the directory to compress
541 /// - `base_path`: Base path for calculating relative paths
542 ///
543 /// # Returns
544 /// - `Ok(usize)`: Number of files compressed
545 /// - `Err(BatchError)`: Error during compression
546 fn compress_directory(
547 &self,
548 zip_writer: &mut ZipWriter<File>,
549 dir_path: &Path,
550 base_path: &Path,
551 ) -> Result<usize, BatchError> {
552 let mut file_count = 0;
553
554 let entries = fs::read_dir(dir_path).map_err(BatchError::Io)?;
555
556 for entry in entries {
557 let entry = entry.map_err(BatchError::Io)?;
558 let entry_path = entry.path();
559
560 if entry_path.is_file() {
561 if self.should_include_file(&entry_path) {
562 let archive_path = if self.preserve_structure {
563 entry_path
564 .strip_prefix(base_path)
565 .unwrap_or(&entry_path)
566 .to_string_lossy()
567 .replace('\\', "/") // Normalize path separators for ZIP
568 } else {
569 entry_path
570 .file_name()
571 .unwrap_or_default()
572 .to_string_lossy()
573 .to_string()
574 };
575
576 self.compress_file(zip_writer, &entry_path, &archive_path)?;
577 file_count += 1;
578 }
579 } else if entry_path.is_dir() {
580 file_count += self.compress_directory(zip_writer, &entry_path, base_path)?;
581 }
582 }
583
584 Ok(file_count)
585 }
586}
587
588impl Tasklet for ZipTasklet {
589 /// Executes the ZIP compression operation.
590 ///
591 /// This method creates a ZIP archive from the configured source path,
592 /// applying any specified filters and compression settings.
593 ///
594 /// # Parameters
595 /// - `step_execution`: The current step execution context
596 ///
597 /// # Returns
598 /// - `Ok(RepeatStatus::Finished)`: Compression completed successfully
599 /// - `Err(BatchError)`: Error during compression
600 ///
601 /// # Examples
602 ///
603 /// ```ignore
604 /// use spring_batch_rs::core::step::{StepExecution, Tasklet};
605 /// use spring_batch_rs::tasklet::zip::ZipTasklet;
606 /// use std::path::Path;
607 ///
608 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
609 /// let tasklet = ZipTasklet::new()
610 /// .source_path(Path::new("./data"))
611 /// .target_path(Path::new("./archive.zip"))?;
612 ///
613 /// let step_execution = StepExecution::new("zip-step");
614 /// let result = tasklet.execute(&step_execution)?;
615 /// # Ok(())
616 /// # }
617 /// ```
618 fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
619 info!(
620 "Starting ZIP compression: {} -> {}",
621 self.source_path.display(),
622 self.target_path.display()
623 );
624
625 // Create the ZIP file
626 let zip_file = File::create(&self.target_path).map_err(BatchError::Io)?;
627 let mut zip_writer = ZipWriter::new(zip_file);
628
629 let file_count = if self.source_path.is_file() {
630 // Compress single file
631 if self.should_include_file(&self.source_path) {
632 let archive_name = self
633 .source_path
634 .file_name()
635 .unwrap_or_default()
636 .to_string_lossy()
637 .to_string();
638
639 self.compress_file(&mut zip_writer, &self.source_path, &archive_name)?;
640 1
641 } else {
642 warn!(
643 "Source file excluded by filters: {}",
644 self.source_path.display()
645 );
646 0
647 }
648 } else if self.source_path.is_dir() {
649 // Compress directory
650 self.compress_directory(&mut zip_writer, &self.source_path, &self.source_path)?
651 } else {
652 return Err(BatchError::Io(io::Error::new(
653 io::ErrorKind::InvalidInput,
654 format!("Invalid source path: {}", self.source_path.display()),
655 )));
656 };
657
658 // Finalize the ZIP file
659 zip_writer
660 .finish()
661 .map_err(|e| BatchError::Io(io::Error::other(e)))?;
662
663 info!(
664 "ZIP compression completed successfully. {} files compressed to {}",
665 file_count,
666 self.target_path.display()
667 );
668
669 Ok(RepeatStatus::Finished)
670 }
671}
672
673/// Builder for creating ZipTasklet instances with a fluent interface.
674///
675/// This builder provides a convenient way to configure ZIP tasklets with
676/// various options such as compression level, file filters, and directory
677/// structure preservation.
678///
679/// # Examples
680///
681/// ```rust
682/// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
683///
684/// # fn example() -> Result<(), spring_batch_rs::BatchError> {
685/// let tasklet = ZipTaskletBuilder::new()
686/// .source_path("./data")
687/// .target_path("./backup.zip")
688/// .compression_level(9)
689/// .include_pattern("*.txt")
690/// .exclude_pattern("*.tmp")
691/// .preserve_structure(true)
692/// .build()?;
693/// # Ok(())
694/// # }
695/// ```
696pub struct ZipTaskletBuilder {
697 source_path: Option<PathBuf>,
698 target_path: Option<PathBuf>,
699 compression_level: i32,
700 include_pattern: Option<String>,
701 exclude_pattern: Option<String>,
702 preserve_structure: bool,
703}
704
705impl Default for ZipTaskletBuilder {
706 fn default() -> Self {
707 Self::new()
708 }
709}
710
711impl ZipTaskletBuilder {
712 /// Creates a new ZipTaskletBuilder with default settings.
713 ///
714 /// # Examples
715 ///
716 /// ```rust
717 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
718 ///
719 /// let builder = ZipTaskletBuilder::new();
720 /// ```
721 pub fn new() -> Self {
722 Self {
723 source_path: None,
724 target_path: None,
725 compression_level: 6,
726 include_pattern: None,
727 exclude_pattern: None,
728 preserve_structure: true,
729 }
730 }
731
732 /// Sets the source path to compress.
733 ///
734 /// # Parameters
735 /// - `path`: Path to the file or directory to compress
736 ///
737 /// # Examples
738 ///
739 /// ```rust
740 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
741 ///
742 /// let builder = ZipTaskletBuilder::new()
743 /// .source_path("./data");
744 /// ```
745 pub fn source_path<P: AsRef<Path>>(mut self, path: P) -> Self {
746 self.source_path = Some(path.as_ref().to_path_buf());
747 self
748 }
749
750 /// Sets the target ZIP file path.
751 ///
752 /// # Parameters
753 /// - `path`: Path where the ZIP file will be created
754 ///
755 /// # Examples
756 ///
757 /// ```rust
758 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
759 ///
760 /// let builder = ZipTaskletBuilder::new()
761 /// .target_path("./archive.zip");
762 /// ```
763 pub fn target_path<P: AsRef<Path>>(mut self, path: P) -> Self {
764 self.target_path = Some(path.as_ref().to_path_buf());
765 self
766 }
767
768 /// Sets the compression level.
769 ///
770 /// # Parameters
771 /// - `level`: Compression level (0-9, where 0 is no compression and 9 is maximum)
772 ///
773 /// # Examples
774 ///
775 /// ```rust
776 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
777 ///
778 /// let builder = ZipTaskletBuilder::new()
779 /// .compression_level(9); // Maximum compression
780 /// ```
781 pub fn compression_level(mut self, level: i32) -> Self {
782 self.compression_level = level.clamp(0, 9);
783 self
784 }
785
786 /// Sets a pattern for files to include in the archive.
787 ///
788 /// # Parameters
789 /// - `pattern`: Glob pattern for files to include
790 ///
791 /// # Examples
792 ///
793 /// ```rust
794 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
795 ///
796 /// let builder = ZipTaskletBuilder::new()
797 /// .include_pattern("*.log");
798 /// ```
799 pub fn include_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
800 self.include_pattern = Some(pattern.into());
801 self
802 }
803
804 /// Sets a pattern for files to exclude from the archive.
805 ///
806 /// # Parameters
807 /// - `pattern`: Glob pattern for files to exclude
808 ///
809 /// # Examples
810 ///
811 /// ```rust
812 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
813 ///
814 /// let builder = ZipTaskletBuilder::new()
815 /// .exclude_pattern("*.tmp");
816 /// ```
817 pub fn exclude_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
818 self.exclude_pattern = Some(pattern.into());
819 self
820 }
821
822 /// Sets whether to preserve directory structure.
823 ///
824 /// # Parameters
825 /// - `preserve`: If true, maintains directory structure; if false, flattens all files
826 ///
827 /// # Examples
828 ///
829 /// ```rust
830 /// use spring_batch_rs::tasklet::zip::ZipTaskletBuilder;
831 ///
832 /// let builder = ZipTaskletBuilder::new()
833 /// .preserve_structure(false); // Flatten files
834 /// ```
835 pub fn preserve_structure(mut self, preserve: bool) -> Self {
836 self.preserve_structure = preserve;
837 self
838 }
839
840 /// Builds the ZipTasklet instance.
841 ///
842 /// # Returns
843 /// - `Ok(ZipTasklet)`: Successfully created tasklet
844 /// - `Err(BatchError)`: Error if required parameters are missing or invalid
845 ///
846 /// # Errors
847 /// - Returns error if source_path or target_path are not set
848 /// - Returns error if source path doesn't exist
849 /// - Returns error if target directory cannot be created
850 pub fn build(self) -> Result<ZipTasklet, BatchError> {
851 let source_path = self
852 .source_path
853 .ok_or_else(|| BatchError::Configuration("Source path is required".to_string()))?;
854
855 let target_path = self
856 .target_path
857 .ok_or_else(|| BatchError::Configuration("Target path is required".to_string()))?;
858
859 let mut tasklet = ZipTasklet::new()
860 .source_path(source_path)
861 .target_path(target_path)?
862 .compression_level(self.compression_level)
863 .preserve_structure(self.preserve_structure);
864
865 if let Some(pattern) = self.include_pattern {
866 tasklet = tasklet.include_pattern(pattern);
867 }
868
869 if let Some(pattern) = self.exclude_pattern {
870 tasklet = tasklet.exclude_pattern(pattern);
871 }
872
873 Ok(tasklet)
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use std::fs;
881 use tempfile::TempDir;
882
883 /// Creates a test directory structure for testing.
884 fn create_test_structure(base_dir: &Path) -> Result<(), io::Error> {
885 // Create directories
886 fs::create_dir_all(base_dir.join("subdir1"))?;
887 fs::create_dir_all(base_dir.join("subdir2"))?;
888
889 // Create files
890 fs::write(base_dir.join("file1.txt"), "Content of file1")?;
891 fs::write(base_dir.join("file2.log"), "Log content")?;
892 fs::write(base_dir.join("file3.tmp"), "Temporary content")?;
893 fs::write(
894 base_dir.join("subdir1").join("nested.txt"),
895 "Nested content",
896 )?;
897 fs::write(base_dir.join("subdir2").join("data.log"), "Data log")?;
898
899 Ok(())
900 }
901
902 #[test]
903 fn test_zip_tasklet_creation() -> Result<(), BatchError> {
904 let temp_dir = TempDir::new().unwrap();
905 let source_path = temp_dir.path().join("source");
906 let target_path = temp_dir.path().join("archive.zip");
907
908 fs::create_dir(&source_path).unwrap();
909 fs::write(source_path.join("test.txt"), "test content").unwrap();
910
911 let tasklet = ZipTasklet::new()
912 .source_path(&source_path)
913 .target_path(&target_path)?;
914 assert_eq!(tasklet.source_path, source_path);
915 assert_eq!(tasklet.target_path, target_path);
916 assert_eq!(tasklet.compression_level, 6);
917
918 Ok(())
919 }
920
921 #[test]
922 fn test_zip_tasklet_builder() -> Result<(), BatchError> {
923 let temp_dir = TempDir::new().unwrap();
924 let source_path = temp_dir.path().join("source");
925 let target_path = temp_dir.path().join("archive.zip");
926
927 fs::create_dir(&source_path).unwrap();
928 fs::write(source_path.join("test.txt"), "test content").unwrap();
929
930 let tasklet = ZipTaskletBuilder::new()
931 .source_path(&source_path)
932 .target_path(&target_path)
933 .compression_level(9)
934 .include_pattern("*.txt")
935 .exclude_pattern("*.tmp")
936 .preserve_structure(false)
937 .build()?;
938
939 assert_eq!(tasklet.compression_level, 9);
940 assert_eq!(tasklet.include_pattern, Some("*.txt".to_string()));
941 assert_eq!(tasklet.exclude_pattern, Some("*.tmp".to_string()));
942 assert!(!tasklet.preserve_structure);
943
944 Ok(())
945 }
946
947 #[test]
948 fn test_zip_single_file() -> Result<(), BatchError> {
949 let temp_dir = TempDir::new().unwrap();
950 let source_file = temp_dir.path().join("test.txt");
951 let target_zip = temp_dir.path().join("archive.zip");
952
953 fs::write(&source_file, "Hello, World!").unwrap();
954
955 let tasklet = ZipTasklet::new()
956 .source_path(&source_file)
957 .target_path(&target_zip)?;
958 let step_execution = StepExecution::new("test-step");
959
960 let result = tasklet.execute(&step_execution)?;
961 assert_eq!(result, RepeatStatus::Finished);
962 assert!(target_zip.exists());
963
964 Ok(())
965 }
966
967 #[test]
968 fn test_zip_directory() -> Result<(), BatchError> {
969 let temp_dir = TempDir::new().unwrap();
970 let source_dir = temp_dir.path().join("source");
971 let target_zip = temp_dir.path().join("archive.zip");
972
973 fs::create_dir(&source_dir).unwrap();
974 create_test_structure(&source_dir).unwrap();
975
976 let tasklet = ZipTasklet::new()
977 .source_path(&source_dir)
978 .target_path(&target_zip)?;
979 let step_execution = StepExecution::new("test-step");
980
981 let result = tasklet.execute(&step_execution)?;
982 assert_eq!(result, RepeatStatus::Finished);
983 assert!(target_zip.exists());
984
985 Ok(())
986 }
987
988 #[test]
989 fn test_pattern_matching() {
990 let tasklet = ZipTasklet::new()
991 .include_pattern("*.txt")
992 .exclude_pattern("*.tmp")
993 .preserve_structure(true);
994
995 assert!(tasklet.matches_pattern("file.txt", "*.txt"));
996 assert!(!tasklet.matches_pattern("file.log", "*.txt"));
997 assert!(tasklet.matches_pattern("path/to/file.txt", "**/*.txt"));
998 assert!(!tasklet.matches_pattern("file.txt", "*.log"));
999
1000 assert!(tasklet.should_include_file(Path::new("test.txt")));
1001 assert!(!tasklet.should_include_file(Path::new("test.tmp")));
1002 assert!(!tasklet.should_include_file(Path::new("test.log")));
1003
1004 // Wildcard patterns: * and ** alone match everything
1005 assert!(tasklet.matches_pattern("anything", "*"));
1006 assert!(tasklet.matches_pattern("deep/path/file.txt", "**"));
1007
1008 // ** pattern where full path doesn't match but a segment does
1009 // e.g. "a/dir" vs "**/dir" — full path != "dir", but segment "dir" == "dir"
1010 assert!(tasklet.matches_pattern("a/dir", "**/dir"));
1011
1012 // ** pattern with non-empty prefix like "src/**.rs"
1013 assert!(tasklet.matches_pattern("src/main.rs", "src/**.rs"));
1014 assert!(!tasklet.matches_pattern("lib/main.rs", "src/**.rs"));
1015
1016 // Exact match (no wildcard in pattern)
1017 assert!(tasklet.matches_pattern("file.txt", "file.txt"));
1018 assert!(!tasklet.matches_pattern("other.txt", "file.txt"));
1019 }
1020
1021 #[test]
1022 fn should_create_zip_tasklet_builder_via_default() {
1023 let _b = ZipTaskletBuilder::default();
1024 }
1025
1026 #[test]
1027 fn should_zip_file_with_stored_compression() -> Result<(), BatchError> {
1028 let temp_dir = TempDir::new().unwrap();
1029 let source_file = temp_dir.path().join("data.txt");
1030 let target_zip = temp_dir.path().join("stored.zip");
1031
1032 fs::write(&source_file, "content for stored compression").unwrap();
1033
1034 let mut tasklet = ZipTasklet::new()
1035 .source_path(&source_file)
1036 .target_path(&target_zip)?;
1037 tasklet.set_compression_level(0); // Stored (no compression)
1038
1039 let step_execution = StepExecution::new("test-step");
1040 let result = tasklet.execute(&step_execution)?;
1041 assert_eq!(result, RepeatStatus::Finished);
1042 assert!(target_zip.exists());
1043 Ok(())
1044 }
1045
1046 #[test]
1047 fn should_zip_directory_without_preserving_structure() -> Result<(), BatchError> {
1048 let temp_dir = TempDir::new().unwrap();
1049 let source_dir = temp_dir.path().join("source");
1050 let target_zip = temp_dir.path().join("flat.zip");
1051
1052 fs::create_dir(&source_dir).unwrap();
1053 fs::write(source_dir.join("a.txt"), "file a").unwrap();
1054 fs::write(source_dir.join("b.txt"), "file b").unwrap();
1055
1056 let tasklet = ZipTasklet::new()
1057 .source_path(&source_dir)
1058 .preserve_structure(false)
1059 .target_path(&target_zip)?;
1060
1061 let step_execution = StepExecution::new("test-step");
1062 let result = tasklet.execute(&step_execution)?;
1063 assert_eq!(result, RepeatStatus::Finished);
1064 assert!(target_zip.exists());
1065 Ok(())
1066 }
1067
1068 #[test]
1069 fn should_exclude_file_when_filter_does_not_match() -> Result<(), BatchError> {
1070 let temp_dir = TempDir::new().unwrap();
1071 let source_file = temp_dir.path().join("data.log");
1072 let target_zip = temp_dir.path().join("filtered.zip");
1073
1074 fs::write(&source_file, "log content").unwrap();
1075
1076 // Only include .txt files → data.log is excluded
1077 let tasklet = ZipTasklet::new()
1078 .source_path(&source_file)
1079 .include_pattern("*.txt")
1080 .target_path(&target_zip)?;
1081
1082 let step_execution = StepExecution::new("test-step");
1083 let result = tasklet.execute(&step_execution)?;
1084 assert_eq!(result, RepeatStatus::Finished);
1085 // ZIP exists but contains 0 files (the source was excluded)
1086 assert!(target_zip.exists());
1087 Ok(())
1088 }
1089
1090 #[test]
1091 fn test_compression_levels() -> Result<(), BatchError> {
1092 let temp_dir = TempDir::new().unwrap();
1093 let source_file = temp_dir.path().join("test.txt");
1094 let target_zip = temp_dir.path().join("archive.zip");
1095
1096 fs::write(&source_file, "Hello, World!".repeat(1000)).unwrap();
1097
1098 let mut tasklet = ZipTasklet::new()
1099 .source_path(&source_file)
1100 .target_path(&target_zip)?;
1101 tasklet.set_compression_level(0); // No compression
1102 assert_eq!(tasklet.compression_level, 0);
1103
1104 tasklet.set_compression_level(15); // Should clamp to 9
1105 assert_eq!(tasklet.compression_level, 9);
1106
1107 tasklet.set_compression_level(-5); // Should clamp to 0
1108 assert_eq!(tasklet.compression_level, 0);
1109
1110 Ok(())
1111 }
1112
1113 #[test]
1114 fn test_builder_validation() {
1115 let result = ZipTaskletBuilder::new().build();
1116 assert!(result.is_err());
1117
1118 let result = ZipTaskletBuilder::new()
1119 .source_path("/nonexistent/path")
1120 .build();
1121 assert!(result.is_err());
1122
1123 let result = ZipTaskletBuilder::new()
1124 .target_path("/some/path.zip")
1125 .build();
1126 assert!(result.is_err());
1127 }
1128
1129 #[test]
1130 fn test_nonexistent_source() {
1131 let result = ZipTasklet::new()
1132 .source_path("/nonexistent/path")
1133 .target_path("/tmp/test.zip");
1134 assert!(result.is_err());
1135 }
1136}