blocks 0.1.0

A high-performance Rust library for block-based content editing with JSON, Markdown, and HTML support
Documentation
/// Caching module for conversion results
///
/// Provides LRU and TTL-based caching to optimize repeated conversions.
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;

/// Cache entry with optional TTL
#[derive(Debug, Clone)]
struct CacheEntry {
    value: String,
    created_at: u64,
    ttl_seconds: Option<u64>,
}

impl CacheEntry {
    /// Checks if the cache entry has expired
    fn is_expired(&self) -> bool {
        if let Some(ttl) = self.ttl_seconds {
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_secs();
            now > self.created_at + ttl
        } else {
            false
        }
    }
}

/// LRU (Least Recently Used) conversion cache with optional TTL support
///
/// # Example
///
/// ```rust
/// use blocks::cache::ConversionCache;
/// use blocks::ConversionFormat;
///
/// let mut cache = ConversionCache::new(100);
/// cache.set_ttl(Some(300)); // 5 minutes
/// ```
pub struct ConversionCache {
    cache: HashMap<String, CacheEntry>,
    max_size: usize,
    ttl_seconds: Option<u64>,
}

impl ConversionCache {
    /// Creates a new conversion cache with a maximum size
    ///
    /// # Arguments
    ///
    /// * `max_size` - Maximum number of entries in the cache
    pub fn new(max_size: usize) -> Self {
        Self {
            cache: HashMap::new(),
            max_size,
            ttl_seconds: None,
        }
    }

    /// Sets the TTL (Time To Live) for all cache entries
    ///
    /// # Arguments
    ///
    /// * `ttl_seconds` - Time to live in seconds, or None for no expiration
    pub fn set_ttl(&mut self, ttl_seconds: Option<u64>) {
        self.ttl_seconds = ttl_seconds;
    }

    /// Generates a cache key for a document and format
    fn generate_key(doc_id: &Uuid, format: &str, doc_updated_at: u64) -> String {
        format!("{}-{}-{}", doc_id, format, doc_updated_at)
    }

    /// Gets a value from the cache
    ///
    /// # Arguments
    ///
    /// * `doc_id` - Document ID
    /// * `format` - Conversion format (e.g., "markdown", "html")
    /// * `doc_updated_at` - Document's last update timestamp
    pub fn get(&mut self, doc_id: &Uuid, format: &str, doc_updated_at: u64) -> Option<String> {
        let key = Self::generate_key(doc_id, format, doc_updated_at);

        if let Some(entry) = self.cache.get(&key) {
            if !entry.is_expired() {
                return Some(entry.value.clone());
            } else {
                self.cache.remove(&key);
            }
        }

        None
    }

    /// Sets a value in the cache
    ///
    /// # Arguments
    ///
    /// * `doc_id` - Document ID
    /// * `format` - Conversion format
    /// * `doc_updated_at` - Document's last update timestamp
    /// * `value` - The value to cache
    pub fn set(&mut self, doc_id: &Uuid, format: &str, doc_updated_at: u64, value: String) {
        // If cache is full, remove entries (simple strategy: remove all expired entries first)
        if self.cache.len() >= self.max_size {
            let expired_keys: Vec<_> = self
                .cache
                .iter()
                .filter(|(_, entry)| entry.is_expired())
                .map(|(k, _)| k.clone())
                .collect();

            for key in expired_keys {
                self.cache.remove(&key);
            }

            // If still full, clear the entire cache (could implement LRU eviction here)
            if self.cache.len() >= self.max_size {
                self.cache.clear();
            }
        }

        let key = Self::generate_key(doc_id, format, doc_updated_at);
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();

        let entry = CacheEntry {
            value,
            created_at: now,
            ttl_seconds: self.ttl_seconds,
        };

        self.cache.insert(key, entry);
    }

    /// Clears a specific cache entry
    pub fn invalidate(&mut self, doc_id: &Uuid, format: &str, doc_updated_at: u64) {
        let key = Self::generate_key(doc_id, format, doc_updated_at);
        self.cache.remove(&key);
    }

    /// Clears all cache entries for a document
    pub fn invalidate_document(&mut self, doc_id: &Uuid) {
        let doc_id_str = doc_id.to_string();
        self.cache.retain(|k, _| !k.starts_with(&doc_id_str));
    }

    /// Clears the entire cache
    pub fn clear(&mut self) {
        self.cache.clear();
    }

    /// Returns the number of entries currently in the cache
    pub fn size(&self) -> usize {
        self.cache.len()
    }

    /// Returns statistics about the cache
    pub fn stats(&self) -> CacheStats {
        let total_entries = self.cache.len();
        let expired_entries = self
            .cache
            .values()
            .filter(|entry| entry.is_expired())
            .count();

        CacheStats {
            total_entries,
            expired_entries,
            valid_entries: total_entries - expired_entries,
            max_size: self.max_size,
        }
    }

    /// Removes all expired entries
    pub fn cleanup_expired(&mut self) {
        let expired_keys: Vec<_> = self
            .cache
            .iter()
            .filter(|(_, entry)| entry.is_expired())
            .map(|(k, _)| k.clone())
            .collect();

        for key in expired_keys {
            self.cache.remove(&key);
        }
    }
}

/// Statistics about the cache
#[derive(Debug, Clone)]
pub struct CacheStats {
    /// Total number of entries (including expired)
    pub total_entries: usize,
    /// Number of expired entries
    pub expired_entries: usize,
    /// Number of valid entries
    pub valid_entries: usize,
    /// Maximum size of the cache
    pub max_size: usize,
}

impl Default for ConversionCache {
    fn default() -> Self {
        Self::new(100)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::thread;
    use std::time::Duration;

    #[test]
    fn test_cache_creation() {
        let cache = ConversionCache::new(100);
        assert_eq!(cache.size(), 0);
        assert_eq!(cache.cache.len(), 0);
    }

    #[test]
    fn test_cache_set_and_get() {
        let mut cache = ConversionCache::new(100);
        let doc_id = Uuid::new_v4();
        let value = "# Hello World".to_string();

        cache.set(&doc_id, "markdown", 1000, value.clone());
        assert_eq!(cache.size(), 1);

        let retrieved = cache.get(&doc_id, "markdown", 1000);
        assert_eq!(retrieved, Some(value));
    }

    #[test]
    fn test_cache_miss_with_different_timestamp() {
        let mut cache = ConversionCache::new(100);
        let doc_id = Uuid::new_v4();
        let value = "# Hello World".to_string();

        cache.set(&doc_id, "markdown", 1000, value);

        // Different timestamp should result in cache miss
        let retrieved = cache.get(&doc_id, "markdown", 1001);
        assert_eq!(retrieved, None);
    }

    #[test]
    fn test_cache_invalidate() {
        let mut cache = ConversionCache::new(100);
        let doc_id = Uuid::new_v4();
        let value = "# Hello World".to_string();

        cache.set(&doc_id, "markdown", 1000, value);
        assert_eq!(cache.size(), 1);

        cache.invalidate(&doc_id, "markdown", 1000);
        assert_eq!(cache.size(), 0);
    }

    #[test]
    fn test_cache_invalidate_document() {
        let mut cache = ConversionCache::new(100);
        let doc_id = Uuid::new_v4();

        cache.set(&doc_id, "markdown", 1000, "markdown".to_string());
        cache.set(&doc_id, "html", 1000, "html".to_string());

        assert_eq!(cache.size(), 2);

        cache.invalidate_document(&doc_id);
        assert_eq!(cache.size(), 0);
    }

    #[test]
    fn test_cache_max_size() {
        let mut cache = ConversionCache::new(3);

        for i in 0..5 {
            let doc_id = Uuid::new_v4();
            cache.set(&doc_id, "markdown", 1000, format!("content{}", i));
        }

        assert!(cache.size() <= 3);
    }

    #[test]
    fn test_cache_ttl_expiration() {
        let mut cache = ConversionCache::new(100);
        cache.set_ttl(Some(1)); // 1 second TTL

        let doc_id = Uuid::new_v4();
        cache.set(&doc_id, "markdown", 1000, "content".to_string());

        assert!(cache.get(&doc_id, "markdown", 1000).is_some());

        thread::sleep(Duration::from_secs(2));

        assert!(cache.get(&doc_id, "markdown", 1000).is_none());
    }

    #[test]
    fn test_cache_clear() {
        let mut cache = ConversionCache::new(100);
        let doc_id = Uuid::new_v4();

        cache.set(&doc_id, "markdown", 1000, "content".to_string());
        assert!(cache.size() > 0);

        cache.clear();
        assert_eq!(cache.size(), 0);
    }

    #[test]
    fn test_cache_stats() {
        let mut cache = ConversionCache::new(100);
        let doc_id = Uuid::new_v4();

        cache.set(&doc_id, "markdown", 1000, "content".to_string());
        cache.set(&doc_id, "html", 1000, "content".to_string());

        let stats = cache.stats();
        assert_eq!(stats.total_entries, 2);
        assert_eq!(stats.valid_entries, 2);
    }

    #[test]
    fn test_cleanup_expired() {
        let mut cache = ConversionCache::new(100);
        cache.set_ttl(Some(1));

        let doc_id = Uuid::new_v4();
        cache.set(&doc_id, "markdown", 1000, "content".to_string());

        thread::sleep(Duration::from_secs(2));

        let initial_size = cache.size();
        cache.cleanup_expired();

        assert!(cache.size() < initial_size);
    }
}