ryo-symbol 0.1.0

Symbol system for Rust codebase - unique identifiers and file path management
Documentation
//! Content cache for file freshness tracking
//!
//! Manages content hashes and modification times for cache invalidation.
//! Separate from file path identity (WorkspaceFilePath handles that).

use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::SystemTime;

use crate::file_path::WorkspaceFilePath;

/// Cache entry for a single file
#[derive(Debug, Clone)]
pub struct CacheEntry {
    /// Content hash (e.g., xxhash of file contents)
    pub hash: u64,
    /// Last modification time
    pub mtime: SystemTime,
}

impl CacheEntry {
    /// Create a new cache entry
    pub fn new(hash: u64, mtime: SystemTime) -> Self {
        Self { hash, mtime }
    }

    /// Check if entry is fresh compared to current mtime
    pub fn is_fresh(&self, current_mtime: SystemTime) -> bool {
        self.mtime == current_mtime
    }
}

/// Freshness status for a file
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Freshness {
    /// File is up to date (mtime matches)
    Fresh,
    /// File has been modified (mtime changed)
    Stale,
    /// File is not in cache
    NotCached,
    /// File no longer exists
    Missing,
}

/// Content cache for tracking file freshness
///
/// # Design
///
/// - Keyed by WorkspaceFilePath (relative path for portability)
/// - Stores hash + mtime for cache invalidation
/// - Separate from path identity concerns
///
/// # Usage
///
/// ```ignore
/// let mut cache = ContentCache::new();
///
/// // Register a file
/// let path = resolver.resolve_relative("src/lib.rs");
/// cache.register(&path, hash, mtime);
///
/// // Check freshness
/// match cache.check_freshness(&path) {
///     Freshness::Fresh => println!("Up to date"),
///     Freshness::Stale => println!("Needs rebuild"),
///     _ => {}
/// }
/// ```
#[derive(Debug, Clone, Default)]
pub struct ContentCache {
    entries: HashMap<PathBuf, CacheEntry>,
}

impl ContentCache {
    /// Create a new empty cache
    pub fn new() -> Self {
        Self {
            entries: HashMap::new(),
        }
    }

    /// Create cache with pre-allocated capacity
    pub fn with_capacity(capacity: usize) -> Self {
        Self {
            entries: HashMap::with_capacity(capacity),
        }
    }

    /// Register or update a file in the cache
    pub fn register(&mut self, path: &WorkspaceFilePath, hash: u64, mtime: SystemTime) {
        let key = path.as_relative().to_path_buf();
        self.entries.insert(key, CacheEntry::new(hash, mtime));
    }

    /// Register a file by reading its current mtime (hash provided)
    pub fn register_with_current_mtime(
        &mut self,
        path: &WorkspaceFilePath,
        hash: u64,
    ) -> io::Result<()> {
        let absolute = path.to_absolute();
        let metadata = fs::metadata(&absolute)?;
        let mtime = metadata.modified()?;
        self.register(path, hash, mtime);
        Ok(())
    }

    /// Get cache entry for a file
    pub fn get(&self, path: &WorkspaceFilePath) -> Option<&CacheEntry> {
        self.entries.get(path.as_relative())
    }

    /// Check if a file is in the cache
    pub fn contains(&self, path: &WorkspaceFilePath) -> bool {
        self.entries.contains_key(path.as_relative())
    }

    /// Remove a file from the cache
    pub fn remove(&mut self, path: &WorkspaceFilePath) -> Option<CacheEntry> {
        self.entries.remove(path.as_relative())
    }

    /// Check freshness of a file
    ///
    /// Compares cached mtime with current file mtime.
    pub fn check_freshness(&self, path: &WorkspaceFilePath) -> Freshness {
        let entry = match self.entries.get(path.as_relative()) {
            Some(e) => e,
            None => return Freshness::NotCached,
        };

        let absolute = path.to_absolute();
        let metadata = match fs::metadata(&absolute) {
            Ok(m) => m,
            Err(_) => return Freshness::Missing,
        };

        let current_mtime = match metadata.modified() {
            Ok(t) => t,
            Err(_) => return Freshness::Stale, // Can't get mtime, assume stale
        };

        if entry.is_fresh(current_mtime) {
            Freshness::Fresh
        } else {
            Freshness::Stale
        }
    }

    /// Get all stale files (files that have been modified)
    pub fn get_stale_files<'a>(
        &self,
        paths: &'a [WorkspaceFilePath],
    ) -> Vec<&'a WorkspaceFilePath> {
        paths
            .iter()
            .filter(|p| self.check_freshness(p) == Freshness::Stale)
            .collect()
    }

    /// Clear all entries
    pub fn clear(&mut self) {
        self.entries.clear();
    }

    /// Get number of cached entries
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Check if cache is empty
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Iterate over all entries
    pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &CacheEntry)> {
        self.entries.iter()
    }
}

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

    fn make_path(relative: &str) -> WorkspaceFilePath {
        WorkspaceFilePath::new_for_test(relative, "/workspace", "test_crate")
    }

    #[test]
    fn test_register_and_get() {
        let mut cache = ContentCache::new();
        let path = make_path("src/lib.rs");
        let mtime = SystemTime::now();

        cache.register(&path, 12345, mtime);

        let entry = cache.get(&path).unwrap();
        assert_eq!(entry.hash, 12345);
        assert_eq!(entry.mtime, mtime);
    }

    #[test]
    fn test_contains() {
        let mut cache = ContentCache::new();
        let path = make_path("src/lib.rs");

        assert!(!cache.contains(&path));

        cache.register(&path, 12345, SystemTime::now());

        assert!(cache.contains(&path));
    }

    #[test]
    fn test_remove() {
        let mut cache = ContentCache::new();
        let path = make_path("src/lib.rs");

        cache.register(&path, 12345, SystemTime::now());
        assert!(cache.contains(&path));

        let removed = cache.remove(&path);
        assert!(removed.is_some());
        assert!(!cache.contains(&path));
    }

    #[test]
    fn test_freshness_not_cached() {
        let cache = ContentCache::new();
        let path = make_path("src/lib.rs");

        assert_eq!(cache.check_freshness(&path), Freshness::NotCached);
    }

    #[test]
    fn test_entry_is_fresh() {
        let mtime = SystemTime::now();
        let entry = CacheEntry::new(12345, mtime);

        assert!(entry.is_fresh(mtime));

        // Different mtime should not be fresh
        let different_mtime = mtime + std::time::Duration::from_secs(1);
        assert!(!entry.is_fresh(different_mtime));
    }
}