bkr 1.0.0

Backup and restore tool for syncing files to AWS S3 with native zstd compression
Documentation
use crate::constants::SYSTEM_IGNORE_FILES;
use glob::Pattern;
use md5::{Digest, Md5};
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use walkdir::WalkDir;

#[derive(Debug, Clone)]
pub struct FileInfo {
    pub absolute_path: PathBuf,
    pub relative_path: String,
    pub size: u64,
    pub last_modified: SystemTime,
    pub md5: String,
}

pub struct FileWalker {
    ignore_patterns: Vec<Pattern>,
}

impl FileWalker {
    pub fn new(ignore_patterns: &[String]) -> Self {
        let patterns = ignore_patterns
            .iter()
            .filter_map(|p| Pattern::new(p).ok())
            .collect();

        Self {
            ignore_patterns: patterns,
        }
    }

    /// Walk directory and collect all files, respecting ignore patterns
    pub fn walk<P: AsRef<Path>>(&self, base_path: P) -> Vec<FileInfo> {
        let base_path = base_path.as_ref();
        let mut files = Vec::new();

        for entry in WalkDir::new(base_path)
            .follow_links(false)
            .into_iter()
            .filter_entry(|e| !self.should_skip_entry(e, base_path))
        {
            let entry = match entry {
                Ok(e) => e,
                Err(err) => {
                    eprintln!(
                        "Warning: Cannot access {}: {}",
                        err.path().map(|p| p.display().to_string()).unwrap_or_default(),
                        err
                    );
                    continue;
                }
            };

            if !entry.file_type().is_file() {
                continue;
            }

            let full_path = entry.path();
            let relative_path = match full_path.strip_prefix(base_path) {
                Ok(p) => p.to_string_lossy().to_string(),
                Err(_) => continue,
            };

            // Check if should be ignored
            if self.should_ignore(&relative_path, entry.file_name().to_str().unwrap_or("")) {
                continue;
            }

            let metadata = match entry.metadata() {
                Ok(m) => m,
                Err(err) => {
                    eprintln!("Warning: Cannot stat {}: {}", full_path.display(), err);
                    continue;
                }
            };

            let md5 = match calculate_file_md5(full_path) {
                Ok(hash) => hash,
                Err(err) => {
                    eprintln!("Warning: Cannot process {}: {}", full_path.display(), err);
                    continue;
                }
            };

            files.push(FileInfo {
                absolute_path: full_path.to_path_buf(),
                relative_path,
                size: metadata.len(),
                last_modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
                md5,
            });
        }

        files
    }

    fn should_skip_entry(&self, entry: &walkdir::DirEntry, base_path: &Path) -> bool {
        let filename = entry.file_name().to_str().unwrap_or("");

        // Skip . and .. entries (handled by walkdir)
        if filename == "." || filename == ".." {
            return true;
        }

        // Check system ignore files
        if SYSTEM_IGNORE_FILES.contains(&filename) {
            return true;
        }

        // For directories, check if the entire directory should be ignored
        if entry.file_type().is_dir() {
            if let Ok(rel_path) = entry.path().strip_prefix(base_path) {
                let rel_str = rel_path.to_string_lossy();
                for pattern in &self.ignore_patterns {
                    if pattern.matches(&rel_str) {
                        return true;
                    }
                }
            }
        }

        false
    }

    fn should_ignore(&self, relative_path: &str, filename: &str) -> bool {
        // Check system ignore files
        if SYSTEM_IGNORE_FILES.contains(&filename) {
            return true;
        }

        // Check against ignore patterns
        for pattern in &self.ignore_patterns {
            if pattern.matches(relative_path) {
                return true;
            }
        }

        false
    }
}

/// Calculate MD5 hash of a file's contents
pub fn calculate_file_md5<P: AsRef<Path>>(file_path: P) -> std::io::Result<String> {
    let mut file = File::open(file_path)?;
    let mut hasher = Md5::new();
    let mut buffer = [0u8; 8192];

    loop {
        let bytes_read = file.read(&mut buffer)?;
        if bytes_read == 0 {
            break;
        }
        hasher.update(&buffer[..bytes_read]);
    }

    Ok(format!("{:x}", hasher.finalize()))
}

/// Calculate MD5 hash of a string
pub fn calculate_string_md5(content: &str) -> String {
    let mut hasher = Md5::new();
    hasher.update(content.as_bytes());
    format!("{:x}", hasher.finalize())
}

/// Format bytes as human-readable string (e.g., "1.50 MB")
pub fn format_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
    let mut size = bytes as f64;
    let mut unit_index = 0;

    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
        size /= 1024.0;
        unit_index += 1;
    }

    format!("{:.2} {}", size, UNITS[unit_index])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_calculate_string_md5() {
        let hash = calculate_string_md5("hello");
        assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
    }

    #[test]
    fn test_format_size() {
        assert_eq!(format_size(0), "0.00 B");
        assert_eq!(format_size(1024), "1.00 KB");
        assert_eq!(format_size(1024 * 1024), "1.00 MB");
        assert_eq!(format_size(1536), "1.50 KB");
    }
}