use crate::constants::{defaults, ARCHIVE_EXTENSION};
use crate::error::{BakerError, Result};
use crate::walker::{calculate_file_md5, format_size};
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tempfile::NamedTempFile;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct FileSignature {
pub path: String,
pub size: u64,
pub md5: String,
}
#[derive(Debug, Clone)]
pub struct DirectorySignature {
pub signature: String,
pub files_count: usize,
pub total_size: u64,
pub files: Vec<FileSignature>,
}
pub struct CompressionManager {
default_level: i32,
}
impl Default for CompressionManager {
fn default() -> Self {
Self::new()
}
}
impl CompressionManager {
pub fn new() -> Self {
Self {
default_level: defaults::ZSTD_LEVEL,
}
}
pub fn calculate_directory_signature<P: AsRef<Path>>(
&self,
dir_path: P,
) -> Result<DirectorySignature> {
let dir_path = dir_path.as_ref();
if !dir_path.exists() {
return Err(BakerError::PathNotFound(format!(
"Directory does not exist: {}",
dir_path.display()
)));
}
if !dir_path.is_dir() {
return Err(BakerError::Config(format!(
"Not a directory: {}",
dir_path.display()
)));
}
let mut files = Vec::new();
let mut total_size = 0u64;
for entry in WalkDir::new(dir_path).follow_links(false) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue, };
if !entry.file_type().is_file() {
continue;
}
let full_path = entry.path();
let relative_path = match full_path.strip_prefix(dir_path) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => continue,
};
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue, };
let md5 = match calculate_file_md5(full_path) {
Ok(h) => h,
Err(_) => continue, };
let size = metadata.len();
total_size += size;
files.push(FileSignature {
path: relative_path,
size,
md5,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
let mut hasher = Sha256::new();
for file in &files {
hasher.update(format!("{}:{}:{}\n", file.path, file.size, file.md5));
}
let signature = format!("{:x}", hasher.finalize());
Ok(DirectorySignature {
signature,
files_count: files.len(),
total_size,
files,
})
}
pub fn calculate_archive_md5<P: AsRef<Path>>(&self, archive_path: P) -> Result<String> {
let archive_path = archive_path.as_ref();
if !archive_path.exists() {
return Err(BakerError::PathNotFound(format!(
"Archive does not exist: {}",
archive_path.display()
)));
}
calculate_file_md5(archive_path).map_err(|e| BakerError::Io(e))
}
pub fn get_latest_mtime<P: AsRef<Path>>(&self, dir_path: P) -> u64 {
let dir_path = dir_path.as_ref();
let mut latest_mtime = 0u64;
for entry in WalkDir::new(dir_path).follow_links(false) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
if let Ok(metadata) = entry.metadata() {
if let Ok(mtime) = metadata.modified() {
if let Ok(duration) = mtime.duration_since(SystemTime::UNIX_EPOCH) {
latest_mtime = latest_mtime.max(duration.as_millis() as u64);
}
}
}
}
latest_mtime
}
pub fn compress_directory<P: AsRef<Path>>(
&self,
dir_path: P,
level: Option<i32>,
verbose: bool,
) -> Result<PathBuf> {
let dir_path = dir_path.as_ref();
let level = level.unwrap_or(self.default_level);
if !dir_path.exists() {
return Err(BakerError::PathNotFound(format!(
"Directory does not exist: {}",
dir_path.display()
)));
}
if !dir_path.is_dir() {
return Err(BakerError::Config(format!(
"Not a directory: {}",
dir_path.display()
)));
}
let dir_name = dir_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| BakerError::Config("Invalid directory name".to_string()))?;
let archive_name = format!("{}{}", dir_name, ARCHIVE_EXTENSION);
let temp_file = NamedTempFile::new()?;
let archive_path = temp_file.into_temp_path().to_path_buf();
if verbose {
println!(" Compressing {} with zstd level {}...", dir_path.display(), level);
}
let original_size = self.get_directory_size(dir_path);
let output_file = File::create(&archive_path)?;
let encoder = zstd::stream::Encoder::new(output_file, level)?;
let mut tar_builder = tar::Builder::new(encoder);
tar_builder
.append_dir_all(dir_name, dir_path)
.map_err(|e| BakerError::Compression(format!("Failed to create tar archive: {}", e)))?;
let encoder = tar_builder
.into_inner()
.map_err(|e| BakerError::Compression(format!("Failed to finish tar archive: {}", e)))?;
encoder
.finish()
.map_err(|e| BakerError::Compression(format!("Failed to finish zstd compression: {}", e)))?;
if verbose {
let compressed_size = fs::metadata(&archive_path)?.len();
let ratio = if original_size > 0 {
(1.0 - compressed_size as f64 / original_size as f64) * 100.0
} else {
0.0
};
println!(
" Compressed: {} -> {} ({:.1}% saved)",
format_size(original_size),
format_size(compressed_size),
ratio
);
}
let final_path = std::env::temp_dir().join(format!(
"baker-{}-{}",
std::process::id(),
archive_name
));
fs::rename(&archive_path, &final_path)?;
Ok(final_path)
}
pub fn decompress_archive<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
archive_path: P,
target_dir: Q,
verbose: bool,
) -> Result<()> {
let archive_path = archive_path.as_ref();
let target_dir = target_dir.as_ref();
if !archive_path.exists() {
return Err(BakerError::PathNotFound(format!(
"Archive does not exist: {}",
archive_path.display()
)));
}
fs::create_dir_all(target_dir)?;
if verbose {
println!(
" Decompressing {} to {}...",
archive_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("archive"),
target_dir.display()
);
}
let file = File::open(archive_path)?;
let decoder = zstd::stream::Decoder::new(file)?;
let mut archive = tar::Archive::new(decoder);
archive
.unpack(target_dir)
.map_err(|e| BakerError::Compression(format!("Failed to extract archive: {}", e)))?;
if verbose {
println!(" Decompressed successfully");
}
Ok(())
}
fn get_directory_size<P: AsRef<Path>>(&self, dir_path: P) -> u64 {
let mut total_size = 0u64;
for entry in WalkDir::new(dir_path).follow_links(false) {
if let Ok(entry) = entry {
if entry.file_type().is_file() {
if let Ok(metadata) = entry.metadata() {
total_size += metadata.len();
}
}
}
}
total_size
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_directory_signature() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.txt");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "hello").unwrap();
let manager = CompressionManager::new();
let sig = manager.calculate_directory_signature(dir.path()).unwrap();
assert_eq!(sig.files_count, 1);
assert!(sig.total_size > 0);
assert!(!sig.signature.is_empty());
}
#[test]
fn test_compress_decompress() {
let dir = tempdir().unwrap();
let subdir = dir.path().join("testdir");
fs::create_dir(&subdir).unwrap();
let file_path = subdir.join("test.txt");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "hello world").unwrap();
let manager = CompressionManager::new();
let archive = manager.compress_directory(&subdir, None, false).unwrap();
assert!(archive.exists());
let extract_dir = tempdir().unwrap();
manager
.decompress_archive(&archive, extract_dir.path(), false)
.unwrap();
let extracted_file = extract_dir.path().join("testdir").join("test.txt");
assert!(extracted_file.exists());
let content = fs::read_to_string(extracted_file).unwrap();
assert!(content.contains("hello world"));
fs::remove_file(archive).ok();
}
}