use crate::{
BatchError,
core::step::{RepeatStatus, StepExecution, Tasklet},
};
use log::{debug, info, warn};
use std::{
fs::{self, File},
io::{self, Write},
path::{Path, PathBuf},
};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
pub struct ZipTasklet {
source_path: PathBuf,
target_path: PathBuf,
compression_level: i32,
include_pattern: Option<String>,
exclude_pattern: Option<String>,
preserve_structure: bool,
}
impl ZipTasklet {
fn new() -> Self {
Self {
source_path: PathBuf::new(),
target_path: PathBuf::new(),
compression_level: 6, include_pattern: None,
exclude_pattern: None,
preserve_structure: true,
}
}
pub fn source_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.source_path = path.as_ref().to_path_buf();
self
}
pub fn target_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self, BatchError> {
let target = path.as_ref().to_path_buf();
if !self.source_path.as_os_str().is_empty() && !self.source_path.exists() {
return Err(BatchError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("Source path does not exist: {}", self.source_path.display()),
)));
}
if let Some(parent) = target.parent()
&& !parent.exists()
{
fs::create_dir_all(parent).map_err(|e| {
BatchError::Io(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("Cannot create target directory {}: {}", parent.display(), e),
))
})?;
}
self.target_path = target;
Ok(self)
}
pub fn compression_level(mut self, level: i32) -> Self {
self.compression_level = level.clamp(0, 9);
self
}
pub fn include_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
self.include_pattern = Some(pattern.into());
self
}
pub fn exclude_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
self.exclude_pattern = Some(pattern.into());
self
}
pub fn preserve_structure(mut self, preserve: bool) -> Self {
self.preserve_structure = preserve;
self
}
pub fn set_compression_level(&mut self, level: i32) {
self.compression_level = level.clamp(0, 9);
}
fn should_include_file(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
if let Some(ref exclude) = self.exclude_pattern
&& self.matches_pattern(&path_str, exclude)
{
debug!("Excluding file due to exclude pattern: {}", path.display());
return false;
}
if let Some(ref include) = self.include_pattern
&& !self.matches_pattern(&path_str, include)
{
debug!("Excluding file due to include pattern: {}", path.display());
return false;
}
true
}
fn matches_pattern(&self, path: &str, pattern: &str) -> bool {
if pattern == "*" || pattern == "**" {
return true;
}
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
if prefix.is_empty() && suffix.starts_with('/') {
let suffix_pattern = &suffix[1..];
return self.matches_simple_pattern(path, suffix_pattern)
|| path
.split('/')
.any(|segment| self.matches_simple_pattern(segment, suffix_pattern));
} else {
return path.starts_with(prefix) && path.ends_with(suffix);
}
}
}
self.matches_simple_pattern(path, pattern)
}
fn matches_simple_pattern(&self, path: &str, pattern: &str) -> bool {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return path.starts_with(prefix) && path.ends_with(suffix);
}
}
path == pattern
}
fn compress_file(
&self,
zip_writer: &mut ZipWriter<File>,
file_path: &Path,
archive_path: &str,
) -> Result<(), BatchError> {
debug!(
"Compressing file: {} -> {}",
file_path.display(),
archive_path
);
let options = if self.compression_level == 0 {
SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
} else {
SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.compression_level(Some(self.compression_level as i64))
};
zip_writer
.start_file(archive_path, options)
.map_err(|e| BatchError::Io(io::Error::other(e)))?;
let file_content = fs::read(file_path).map_err(BatchError::Io)?;
zip_writer
.write_all(&file_content)
.map_err(BatchError::Io)?;
info!("Successfully compressed: {}", archive_path);
Ok(())
}
fn compress_directory(
&self,
zip_writer: &mut ZipWriter<File>,
dir_path: &Path,
base_path: &Path,
) -> Result<usize, BatchError> {
let mut file_count = 0;
let entries = fs::read_dir(dir_path).map_err(BatchError::Io)?;
for entry in entries {
let entry = entry.map_err(BatchError::Io)?;
let entry_path = entry.path();
if entry_path.is_file() {
if self.should_include_file(&entry_path) {
let archive_path = if self.preserve_structure {
entry_path
.strip_prefix(base_path)
.unwrap_or(&entry_path)
.to_string_lossy()
.replace('\\', "/") } else {
entry_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
};
self.compress_file(zip_writer, &entry_path, &archive_path)?;
file_count += 1;
}
} else if entry_path.is_dir() {
file_count += self.compress_directory(zip_writer, &entry_path, base_path)?;
}
}
Ok(file_count)
}
}
impl Tasklet for ZipTasklet {
fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
info!(
"Starting ZIP compression: {} -> {}",
self.source_path.display(),
self.target_path.display()
);
let zip_file = File::create(&self.target_path).map_err(BatchError::Io)?;
let mut zip_writer = ZipWriter::new(zip_file);
let file_count = if self.source_path.is_file() {
if self.should_include_file(&self.source_path) {
let archive_name = self
.source_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
self.compress_file(&mut zip_writer, &self.source_path, &archive_name)?;
1
} else {
warn!(
"Source file excluded by filters: {}",
self.source_path.display()
);
0
}
} else if self.source_path.is_dir() {
self.compress_directory(&mut zip_writer, &self.source_path, &self.source_path)?
} else {
return Err(BatchError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Invalid source path: {}", self.source_path.display()),
)));
};
zip_writer
.finish()
.map_err(|e| BatchError::Io(io::Error::other(e)))?;
info!(
"ZIP compression completed successfully. {} files compressed to {}",
file_count,
self.target_path.display()
);
Ok(RepeatStatus::Finished)
}
}
pub struct ZipTaskletBuilder {
source_path: Option<PathBuf>,
target_path: Option<PathBuf>,
compression_level: i32,
include_pattern: Option<String>,
exclude_pattern: Option<String>,
preserve_structure: bool,
}
impl Default for ZipTaskletBuilder {
fn default() -> Self {
Self::new()
}
}
impl ZipTaskletBuilder {
pub fn new() -> Self {
Self {
source_path: None,
target_path: None,
compression_level: 6,
include_pattern: None,
exclude_pattern: None,
preserve_structure: true,
}
}
pub fn source_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.source_path = Some(path.as_ref().to_path_buf());
self
}
pub fn target_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.target_path = Some(path.as_ref().to_path_buf());
self
}
pub fn compression_level(mut self, level: i32) -> Self {
self.compression_level = level.clamp(0, 9);
self
}
pub fn include_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
self.include_pattern = Some(pattern.into());
self
}
pub fn exclude_pattern<S: Into<String>>(mut self, pattern: S) -> Self {
self.exclude_pattern = Some(pattern.into());
self
}
pub fn preserve_structure(mut self, preserve: bool) -> Self {
self.preserve_structure = preserve;
self
}
pub fn build(self) -> Result<ZipTasklet, BatchError> {
let source_path = self
.source_path
.ok_or_else(|| BatchError::Configuration("Source path is required".to_string()))?;
let target_path = self
.target_path
.ok_or_else(|| BatchError::Configuration("Target path is required".to_string()))?;
let mut tasklet = ZipTasklet::new()
.source_path(source_path)
.target_path(target_path)?
.compression_level(self.compression_level)
.preserve_structure(self.preserve_structure);
if let Some(pattern) = self.include_pattern {
tasklet = tasklet.include_pattern(pattern);
}
if let Some(pattern) = self.exclude_pattern {
tasklet = tasklet.exclude_pattern(pattern);
}
Ok(tasklet)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_structure(base_dir: &Path) -> Result<(), io::Error> {
fs::create_dir_all(base_dir.join("subdir1"))?;
fs::create_dir_all(base_dir.join("subdir2"))?;
fs::write(base_dir.join("file1.txt"), "Content of file1")?;
fs::write(base_dir.join("file2.log"), "Log content")?;
fs::write(base_dir.join("file3.tmp"), "Temporary content")?;
fs::write(
base_dir.join("subdir1").join("nested.txt"),
"Nested content",
)?;
fs::write(base_dir.join("subdir2").join("data.log"), "Data log")?;
Ok(())
}
#[test]
fn test_zip_tasklet_creation() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_path = temp_dir.path().join("source");
let target_path = temp_dir.path().join("archive.zip");
fs::create_dir(&source_path).unwrap();
fs::write(source_path.join("test.txt"), "test content").unwrap();
let tasklet = ZipTasklet::new()
.source_path(&source_path)
.target_path(&target_path)?;
assert_eq!(tasklet.source_path, source_path);
assert_eq!(tasklet.target_path, target_path);
assert_eq!(tasklet.compression_level, 6);
Ok(())
}
#[test]
fn test_zip_tasklet_builder() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_path = temp_dir.path().join("source");
let target_path = temp_dir.path().join("archive.zip");
fs::create_dir(&source_path).unwrap();
fs::write(source_path.join("test.txt"), "test content").unwrap();
let tasklet = ZipTaskletBuilder::new()
.source_path(&source_path)
.target_path(&target_path)
.compression_level(9)
.include_pattern("*.txt")
.exclude_pattern("*.tmp")
.preserve_structure(false)
.build()?;
assert_eq!(tasklet.compression_level, 9);
assert_eq!(tasklet.include_pattern, Some("*.txt".to_string()));
assert_eq!(tasklet.exclude_pattern, Some("*.tmp".to_string()));
assert!(!tasklet.preserve_structure);
Ok(())
}
#[test]
fn test_zip_single_file() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_file = temp_dir.path().join("test.txt");
let target_zip = temp_dir.path().join("archive.zip");
fs::write(&source_file, "Hello, World!").unwrap();
let tasklet = ZipTasklet::new()
.source_path(&source_file)
.target_path(&target_zip)?;
let step_execution = StepExecution::new("test-step");
let result = tasklet.execute(&step_execution)?;
assert_eq!(result, RepeatStatus::Finished);
assert!(target_zip.exists());
Ok(())
}
#[test]
fn test_zip_directory() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let target_zip = temp_dir.path().join("archive.zip");
fs::create_dir(&source_dir).unwrap();
create_test_structure(&source_dir).unwrap();
let tasklet = ZipTasklet::new()
.source_path(&source_dir)
.target_path(&target_zip)?;
let step_execution = StepExecution::new("test-step");
let result = tasklet.execute(&step_execution)?;
assert_eq!(result, RepeatStatus::Finished);
assert!(target_zip.exists());
Ok(())
}
#[test]
fn test_pattern_matching() {
let tasklet = ZipTasklet::new()
.include_pattern("*.txt")
.exclude_pattern("*.tmp")
.preserve_structure(true);
assert!(tasklet.matches_pattern("file.txt", "*.txt"));
assert!(!tasklet.matches_pattern("file.log", "*.txt"));
assert!(tasklet.matches_pattern("path/to/file.txt", "**/*.txt"));
assert!(!tasklet.matches_pattern("file.txt", "*.log"));
assert!(tasklet.should_include_file(Path::new("test.txt")));
assert!(!tasklet.should_include_file(Path::new("test.tmp")));
assert!(!tasklet.should_include_file(Path::new("test.log")));
assert!(tasklet.matches_pattern("anything", "*"));
assert!(tasklet.matches_pattern("deep/path/file.txt", "**"));
assert!(tasklet.matches_pattern("a/dir", "**/dir"));
assert!(tasklet.matches_pattern("src/main.rs", "src/**.rs"));
assert!(!tasklet.matches_pattern("lib/main.rs", "src/**.rs"));
assert!(tasklet.matches_pattern("file.txt", "file.txt"));
assert!(!tasklet.matches_pattern("other.txt", "file.txt"));
}
#[test]
fn should_create_zip_tasklet_builder_via_default() {
let _b = ZipTaskletBuilder::default();
}
#[test]
fn should_zip_file_with_stored_compression() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_file = temp_dir.path().join("data.txt");
let target_zip = temp_dir.path().join("stored.zip");
fs::write(&source_file, "content for stored compression").unwrap();
let mut tasklet = ZipTasklet::new()
.source_path(&source_file)
.target_path(&target_zip)?;
tasklet.set_compression_level(0);
let step_execution = StepExecution::new("test-step");
let result = tasklet.execute(&step_execution)?;
assert_eq!(result, RepeatStatus::Finished);
assert!(target_zip.exists());
Ok(())
}
#[test]
fn should_zip_directory_without_preserving_structure() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let target_zip = temp_dir.path().join("flat.zip");
fs::create_dir(&source_dir).unwrap();
fs::write(source_dir.join("a.txt"), "file a").unwrap();
fs::write(source_dir.join("b.txt"), "file b").unwrap();
let tasklet = ZipTasklet::new()
.source_path(&source_dir)
.preserve_structure(false)
.target_path(&target_zip)?;
let step_execution = StepExecution::new("test-step");
let result = tasklet.execute(&step_execution)?;
assert_eq!(result, RepeatStatus::Finished);
assert!(target_zip.exists());
Ok(())
}
#[test]
fn should_exclude_file_when_filter_does_not_match() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_file = temp_dir.path().join("data.log");
let target_zip = temp_dir.path().join("filtered.zip");
fs::write(&source_file, "log content").unwrap();
let tasklet = ZipTasklet::new()
.source_path(&source_file)
.include_pattern("*.txt")
.target_path(&target_zip)?;
let step_execution = StepExecution::new("test-step");
let result = tasklet.execute(&step_execution)?;
assert_eq!(result, RepeatStatus::Finished);
assert!(target_zip.exists());
Ok(())
}
#[test]
fn test_compression_levels() -> Result<(), BatchError> {
let temp_dir = TempDir::new().unwrap();
let source_file = temp_dir.path().join("test.txt");
let target_zip = temp_dir.path().join("archive.zip");
fs::write(&source_file, "Hello, World!".repeat(1000)).unwrap();
let mut tasklet = ZipTasklet::new()
.source_path(&source_file)
.target_path(&target_zip)?;
tasklet.set_compression_level(0); assert_eq!(tasklet.compression_level, 0);
tasklet.set_compression_level(15); assert_eq!(tasklet.compression_level, 9);
tasklet.set_compression_level(-5); assert_eq!(tasklet.compression_level, 0);
Ok(())
}
#[test]
fn test_builder_validation() {
let result = ZipTaskletBuilder::new().build();
assert!(result.is_err());
let result = ZipTaskletBuilder::new()
.source_path("/nonexistent/path")
.build();
assert!(result.is_err());
let result = ZipTaskletBuilder::new()
.target_path("/some/path.zip")
.build();
assert!(result.is_err());
}
#[test]
fn test_nonexistent_source() {
let result = ZipTasklet::new()
.source_path("/nonexistent/path")
.target_path("/tmp/test.zip");
assert!(result.is_err());
}
}