Skip to main content

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}