prlens 0.1.0

One queue for all your PRs — aggregates GitHub and Bitbucket review requests into a single interactive view
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Generic disk-cache entry. Wraps any serializable data with a fetched_at timestamp and TTL.
/// Used by GitHubProvider (Phase 2) and future BitbucketProvider (Phase 4).
#[derive(Debug, Serialize, Deserialize)]
pub struct CacheEntry<T> {
    pub fetched_at: DateTime<Utc>,
    pub ttl_seconds: u64,
    pub data: T,
}

impl<T> CacheEntry<T> {
    /// Create a new CacheEntry with fetched_at = Utc::now().
    pub fn new(data: T, ttl_seconds: u64) -> Self {
        Self {
            fetched_at: Utc::now(),
            ttl_seconds,
            data,
        }
    }

    /// Returns true if the cache entry is still fresh (within TTL).
    /// Returns false if the entry is expired or the clock has drifted backwards.
    pub fn is_fresh(&self) -> bool {
        let elapsed = Utc::now() - self.fetched_at;
        elapsed.num_seconds() >= 0 && (elapsed.num_seconds() as u64) < self.ttl_seconds
    }
}

/// Read a CacheEntry from a JSON file at `path`.
/// Returns None on any I/O error or deserialization error (silent discard per D-06).
pub fn read_cache<T: for<'de> serde::Deserialize<'de>>(
    path: &std::path::Path,
) -> Option<CacheEntry<T>> {
    let content = std::fs::read_to_string(path).ok()?;
    let entry: CacheEntry<T> = serde_json::from_str(&content)
        .map_err(|e| tracing::debug!("Cache parse error at {:?}: {}", path, e))
        .ok()?;
    tracing::debug!("Cache hit at {:?}", path);
    Some(entry)
}

/// Write data to a JSON cache file at `path` using an atomic rename (via NamedTempFile).
/// Creates parent directories as needed. The write is atomic: a torn/corrupt cache cannot
/// result from concurrent invocations because each write completes as a POSIX rename.
pub fn write_cache<T: serde::Serialize>(
    path: &std::path::Path,
    data: T,
    ttl_seconds: u64,
) -> anyhow::Result<()> {
    // Ensure parent directory exists
    std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new(".")))?;

    let entry = CacheEntry::new(data, ttl_seconds);
    let json = serde_json::to_string_pretty(&entry)?;

    // Write to a temp file in the same directory, then atomically rename
    let dir = path.parent().unwrap_or(std::path::Path::new("."));
    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
    use std::io::Write as _;
    tmp.write_all(json.as_bytes())?;
    tmp.flush()?;
    tmp.persist(path)?;

    Ok(())
}

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

    /// D-05, ARCH-04: CacheEntry serializes and deserializes correctly.
    #[test]
    fn cache_schema_roundtrip() {
        let entry: CacheEntry<Vec<&str>> = CacheEntry::new(vec!["hello"], 60);
        let json = serde_json::to_string(&entry).expect("serialize failed");
        let restored: CacheEntry<Vec<&str>> =
            serde_json::from_str(&json).expect("deserialize failed");
        assert_eq!(restored.data, vec!["hello"]);
        assert_eq!(restored.ttl_seconds, 60);
    }

    /// D-05, ARCH-04: A freshly created entry is fresh.
    #[test]
    fn cache_hit_within_ttl() {
        let entry: CacheEntry<u32> = CacheEntry::new(42u32, 60);
        assert!(entry.is_fresh(), "Expected fresh entry to return is_fresh() == true");
    }

    /// ARCH-04: An entry fetched 61 seconds ago with a 60s TTL is expired.
    #[test]
    fn cache_expired() {
        let entry = CacheEntry {
            fetched_at: Utc::now() - Duration::seconds(61),
            ttl_seconds: 60,
            data: 42u32,
        };
        assert!(!entry.is_fresh(), "Expected expired entry to return is_fresh() == false");
    }

    /// D-06: Corrupt or invalid JSON in the cache file is silently discarded (returns None).
    #[test]
    fn cache_corrupt_discarded() {
        let dir = tempfile::TempDir::new().expect("TempDir failed");
        let path = dir.path().join("corrupt.json");
        std::fs::write(&path, b"not valid json {{{").expect("write failed");
        let result: Option<CacheEntry<String>> = read_cache(&path);
        assert!(result.is_none(), "Expected None for corrupt cache, got Some");
    }

    /// ARCH-04: write_cache then read_cache returns the same data.
    #[test]
    fn cache_write_and_read_roundtrip() {
        let dir = tempfile::TempDir::new().expect("TempDir failed");
        let path = dir.path().join("test_cache.json");
        write_cache(&path, vec!["test"], 60).expect("write_cache failed");
        let result: Option<CacheEntry<Vec<String>>> = read_cache(&path);
        assert!(result.is_some(), "Expected Some after write_cache");
        let entry = result.unwrap();
        assert_eq!(entry.data, vec!["test"]);
        assert_eq!(entry.ttl_seconds, 60);
    }
}