mermaid-cli 0.3.10

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::{BufReader, Read};
use std::path::Path;
use std::time::SystemTime;

use super::types::{CacheEntry, CacheKey, CacheMetadata};

/// File-level cache operations
#[derive(Debug)]
pub struct FileCache {
    cache_dir: std::path::PathBuf,
}

impl FileCache {
    /// Create a new file cache
    pub fn new(cache_dir: std::path::PathBuf) -> Result<Self> {
        // Ensure cache directory exists
        fs::create_dir_all(&cache_dir)?;
        Ok(Self { cache_dir })
    }

    /// Compute SHA256 hash of a file using buffered streaming
    /// Uses 64KB chunks to avoid loading entire file into memory
    pub fn hash_file(path: &Path) -> Result<String> {
        let file = File::open(path)?;
        let mut reader = BufReader::with_capacity(65536, file); // 64KB buffer
        let mut hasher = Sha256::new();
        let mut buffer = [0u8; 65536];

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

        let result = hasher.finalize();
        Ok(format!("{:x}", result))
    }

    /// Generate cache key for a file
    pub fn generate_key(path: &Path) -> Result<CacheKey> {
        let file_hash = Self::hash_file(path)?;
        Ok(CacheKey {
            file_path: path.to_path_buf(),
            file_hash,
        })
    }

    /// Save data to cache with compression
    pub fn save<T>(&self, key: &CacheKey, data: &T) -> Result<()>
    where
        T: serde::Serialize,
    {
        // Serialize data
        let serialized = bincode::serialize(data)?;
        let original_size = serialized.len();

        // Compress data
        let compressed = lz4::block::compress(&serialized, None, true)?;
        let compressed_size = compressed.len();

        // Create metadata
        let metadata = CacheMetadata {
            created_at: SystemTime::now(),
            last_accessed: SystemTime::now(),
            file_size: original_size as u64,
            compressed_size,
            compression_ratio: original_size as f32 / compressed_size as f32,
        };

        // Create cache entry
        let entry = CacheEntry {
            key: key.clone(),
            data: compressed,
            metadata,
        };

        // Generate cache file path
        let cache_path = self.cache_path(key);

        // Ensure parent directory exists
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent)?;
        }

        // Write to file
        let entry_data = bincode::serialize(&entry)?;
        fs::write(cache_path, entry_data)?;

        Ok(())
    }

    /// Load data from cache
    pub fn load<T>(&self, key: &CacheKey) -> Result<Option<T>>
    where
        T: serde::de::DeserializeOwned,
    {
        let cache_path = self.cache_path(key);

        // Check if cache file exists
        if !cache_path.exists() {
            return Ok(None);
        }

        // Read cache entry
        let entry_data = fs::read(&cache_path)?;
        let mut entry: CacheEntry<Vec<u8>> = bincode::deserialize(&entry_data)?;

        // Update last accessed time
        entry.metadata.last_accessed = SystemTime::now();

        // Decompress data
        let decompressed =
            lz4::block::decompress(&entry.data, Some(entry.metadata.file_size as i32))?;

        // Deserialize data
        let data: T = bincode::deserialize(&decompressed)?;

        Ok(Some(data))
    }

    /// Check if cache entry is valid (file hasn't changed)
    pub fn is_valid(&self, key: &CacheKey) -> Result<bool> {
        // Check if file still exists
        if !key.file_path.exists() {
            return Ok(false);
        }

        // Check if hash matches
        let current_hash = Self::hash_file(&key.file_path)?;
        Ok(current_hash == key.file_hash)
    }

    /// Remove cache entry
    pub fn remove(&self, key: &CacheKey) -> Result<()> {
        let cache_path = self.cache_path(key);
        if cache_path.exists() {
            fs::remove_file(cache_path)?;
        }
        Ok(())
    }

    /// Generate cache file path for a key
    fn cache_path(&self, key: &CacheKey) -> std::path::PathBuf {
        // Use first 2 chars of hash for directory sharding
        let hash_prefix = &key.file_hash[..2];
        let cache_name = format!(
            "{}_{}.cache",
            key.file_path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("unknown"),
            &key.file_hash[..8]
        );

        self.cache_dir.join(hash_prefix).join(cache_name)
    }

    /// Get cache statistics with actual sizes from cache entry metadata
    pub fn get_stats(&self) -> Result<CacheStats> {
        let mut total_entries = 0;
        let mut total_size = 0;
        let mut total_compressed_size = 0;

        // Walk cache directory and read actual metadata from each entry
        for entry in fs::read_dir(&self.cache_dir)? {
            let entry = entry?;
            if entry.path().is_dir() {
                for cache_file in fs::read_dir(entry.path())? {
                    let cache_file = cache_file?;
                    let file_metadata = cache_file.metadata()?;
                    total_entries += 1;
                    total_compressed_size += file_metadata.len() as usize;

                    // Read the cache entry to get actual original size from its metadata
                    if let Ok(entry_data) = fs::read(cache_file.path()) {
                        if let Ok(entry) = bincode::deserialize::<CacheEntry<Vec<u8>>>(&entry_data) {
                            total_size += entry.metadata.file_size as usize;
                            continue;
                        }
                    }
                    // Fallback: use compressed size if entry can't be read
                    total_size += file_metadata.len() as usize;
                }
            }
        }

        Ok(CacheStats {
            total_entries,
            total_size,
            total_compressed_size,
            compression_ratio: if total_compressed_size > 0 {
                total_size as f32 / total_compressed_size as f32
            } else {
                1.0
            },
            cache_dir: self.cache_dir.clone(),
        })
    }
}

/// Cache statistics
#[derive(Debug, Clone)]
pub struct CacheStats {
    pub total_entries: usize,
    pub total_size: usize,
    pub total_compressed_size: usize,
    pub compression_ratio: f32,
    pub cache_dir: std::path::PathBuf,
}

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

    // Phase 4 Test Suite: FileCache - core file cache operations

    #[test]
    fn test_cache_key_structure() {
        // Test CacheKey components
        let path = Path::new("src/main.rs");
        let file_hash = "abc123def456".to_string();

        let key = CacheKey {
            file_path: path.to_path_buf(),
            file_hash: file_hash.clone(),
        };

        assert_eq!(key.file_hash, "abc123def456");
        assert_eq!(key.file_path.file_name().unwrap(), "main.rs");
    }

    #[test]
    fn test_cache_metadata_structure() {
        // Test CacheMetadata creation and structure
        let now = SystemTime::now();

        let metadata = CacheMetadata {
            created_at: now,
            last_accessed: now,
            file_size: 1024,
            compressed_size: 512,
            compression_ratio: 2.0,
        };

        assert_eq!(metadata.file_size, 1024);
        assert_eq!(metadata.compressed_size, 512);
        assert_eq!(metadata.compression_ratio, 2.0);
    }

    #[test]
    fn test_cache_entry_structure() {
        // Test CacheEntry structure
        let key = CacheKey {
            file_path: Path::new("test.rs").to_path_buf(),
            file_hash: "test_hash".to_string(),
        };

        let metadata = CacheMetadata {
            created_at: SystemTime::now(),
            last_accessed: SystemTime::now(),
            file_size: 100,
            compressed_size: 50,
            compression_ratio: 2.0,
        };

        let entry = CacheEntry {
            key: key.clone(),
            data: vec![1, 2, 3, 4, 5],
            metadata,
        };

        assert_eq!(entry.data.len(), 5);
        assert_eq!(entry.key.file_hash, "test_hash");
    }

    #[test]
    fn test_cache_stats_structure() {
        // Test CacheStats structure and values
        let stats = CacheStats {
            total_entries: 100,
            total_size: 1_000_000,
            total_compressed_size: 500_000,
            compression_ratio: 2.0,
            cache_dir: Path::new("/cache").to_path_buf(),
        };

        assert_eq!(stats.total_entries, 100);
        assert_eq!(stats.total_size, 1_000_000);
        assert_eq!(stats.compression_ratio, 2.0);
    }

    #[test]
    fn test_compression_ratio_calculation() {
        // Test compression ratio calculation logic
        let test_cases = vec![
            (1000, 500, 2.0),
            (2000, 1000, 2.0),
            (3000, 1000, 3.0),
            (1000, 250, 4.0),
        ];

        for (original, compressed, expected) in test_cases {
            let ratio = original as f32 / compressed as f32;
            assert!((ratio - expected).abs() < 0.01);
        }
    }

    #[test]
    fn test_cache_path_construction() {
        // Test cache path construction with hash prefixing
        let hash = "abc123def456";
        let prefix = &hash[..2]; // "ab"

        assert_eq!(prefix, "ab", "Prefix should be first 2 chars of hash");
    }

    #[test]
    fn test_cache_file_naming() {
        // Test cache file naming convention
        let file_name = "main.rs";
        let hash_short = "abc12345";

        let cache_name = format!("{}_{}.cache", file_name, hash_short);

        assert!(
            cache_name.contains("main.rs"),
            "Should include original filename"
        );
        assert!(cache_name.contains("abc12345"), "Should include hash short");
        assert!(cache_name.ends_with(".cache"), "Should end with .cache");
    }

    #[test]
    fn test_cache_stats_compression_ratio_zero_handling() {
        // Test compression ratio when compressed size is zero
        let total_size = 1000;
        let compressed_size = 0;

        let ratio = if compressed_size > 0 {
            total_size as f32 / compressed_size as f32
        } else {
            1.0 // Default when no compression
        };

        assert_eq!(
            ratio, 1.0,
            "Should default to 1.0 when compressed size is 0"
        );
    }
}