bkr 1.0.0

Backup and restore tool for syncing files to AWS S3 with native zstd compression
Documentation
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,
        }
    }

    /// Calculate a signature for a directory based on all its files
    /// This can be used to detect if the directory content has changed
    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;

        // Collect all files recursively
        for entry in WalkDir::new(dir_path).follow_links(false) {
            let entry = match entry {
                Ok(e) => e,
                Err(_) => continue, // Skip inaccessible entries
            };

            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, // Skip inaccessible files
            };

            let md5 = match calculate_file_md5(full_path) {
                Ok(h) => h,
                Err(_) => continue, // Skip unreadable files
            };

            let size = metadata.len();
            total_size += size;

            files.push(FileSignature {
                path: relative_path,
                size,
                md5,
            });
        }

        // Sort files by path for consistent signature
        files.sort_by(|a, b| a.path.cmp(&b.path));

        // Create combined signature from all file hashes
        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,
        })
    }

    /// Calculate MD5 hash of an archive file
    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))
    }

    /// Get the latest modification time of any file in a directory (fast, no MD5)
    /// This is used for quick mtime-based checks before expensive signature calculations
    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
    }

    /// Compress a directory using native zstd
    /// Returns path to the compressed archive
    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()))?;

        // Create temp archive
        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);
        }

        // Get original size for stats
        let original_size = self.get_directory_size(dir_path);

        // Create tar archive in memory, then compress with zstd
        let output_file = File::create(&archive_path)?;
        let encoder = zstd::stream::Encoder::new(output_file, level)?;
        let mut tar_builder = tar::Builder::new(encoder);

        // Add directory contents to tar
        tar_builder
            .append_dir_all(dir_name, dir_path)
            .map_err(|e| BakerError::Compression(format!("Failed to create tar archive: {}", e)))?;

        // Finish tar and zstd
        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
            );
        }

        // Return a stable path (move from temp)
        let final_path = std::env::temp_dir().join(format!(
            "baker-{}-{}",
            std::process::id(),
            archive_name
        ));
        fs::rename(&archive_path, &final_path)?;

        Ok(final_path)
    }

    /// Decompress a zstd archive to a directory
    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()
            )));
        }

        // Ensure target directory exists
        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()
            );
        }

        // Open and decompress
        let file = File::open(archive_path)?;
        let decoder = zstd::stream::Decoder::new(file)?;
        let mut archive = tar::Archive::new(decoder);

        // Extract to target directory
        archive
            .unpack(target_dir)
            .map_err(|e| BakerError::Compression(format!("Failed to extract archive: {}", e)))?;

        if verbose {
            println!("  Decompressed successfully");
        }

        Ok(())
    }

    /// Get total size of directory recursively
    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();

        // Compress
        let archive = manager.compress_directory(&subdir, None, false).unwrap();
        assert!(archive.exists());

        // Decompress
        let extract_dir = tempdir().unwrap();
        manager
            .decompress_archive(&archive, extract_dir.path(), false)
            .unwrap();

        // Verify
        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"));

        // Cleanup
        fs::remove_file(archive).ok();
    }
}