travelagent-core 1.10.2

Core library for travelagent code review tool
Documentation
//! Per-PR JSON cache for remote PR review data.
//!
//! Caches fetched PR data (diff files, commits, comments) to avoid
//! redundant API calls when re-opening the same PR. The cache is
//! invalidated when the PR's head branch changes (new commits pushed).

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::forge::{PrId, PrMetadata, RemoteComment};
use crate::model::DiffFile;
use crate::vcs::CommitInfo;

pub const CACHE_VERSION: u32 = 1;

#[derive(Serialize, Deserialize)]
pub struct PrCache {
    pub version: u32,
    pub head_sha: String,
    pub metadata: PrMetadata,
    pub diff_files: Vec<DiffFile>,
    pub commits: Vec<CommitInfo>,
    pub comments: Vec<RemoteComment>,
}

/// Get the cache file path for a PR.
/// Location: $TMPDIR/travelagent/{host}/{owner}/{repo}/pr-{number}.json
/// The host parameter scopes the cache to avoid collisions between
/// github.com and self-hosted instances with the same owner/repo/number.
fn cache_path(id: &PrId, host: &str) -> PathBuf {
    let tmp = std::env::temp_dir();
    tmp.join("travelagent")
        .join(host)
        .join(&id.owner)
        .join(&id.repo)
        .join(format!("pr-{}.json", id.number))
}

/// Read a cached PR if it exists and the head SHA matches.
/// `host` scopes the cache to the forge instance (e.g., "github.com").
#[must_use]
pub fn read_cache(id: &PrId, host: &str, head_sha: &str) -> Option<PrCache> {
    let path = cache_path(id, host);
    let contents = std::fs::read_to_string(&path).ok()?;
    let cache: PrCache = serde_json::from_str(&contents).ok()?;
    if cache.version != CACHE_VERSION || cache.head_sha != head_sha {
        return None;
    }
    Some(cache)
}

/// Write PR data to cache.
/// `host` scopes the cache to the forge instance (e.g., "github.com").
pub fn write_cache(id: &PrId, host: &str, cache: &PrCache) -> std::io::Result<()> {
    let path = cache_path(id, host);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string(cache).map_err(|e| std::io::Error::other(e.to_string()))?;
    std::fs::write(&path, json)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::forge::{MergeableStatus, PrState};
    use chrono::Utc;

    #[allow(dead_code)]
    fn test_pr_id() -> PrId {
        PrId {
            owner: "test-owner".into(),
            repo: "test-repo".into(),
            number: 42,
        }
    }

    fn test_metadata() -> PrMetadata {
        PrMetadata {
            title: "Test PR".into(),
            body: "Description".into(),
            author: "alice".into(),
            state: PrState::Open,
            base_branch: "main".into(),
            head_branch: "feature".into(),
            head_sha: "abc123def456".into(),
            created_at: Utc::now(),
            mergeable: Some(MergeableStatus::Clean),
            is_draft: false,
        }
    }

    fn test_cache(head_sha: &str) -> PrCache {
        PrCache {
            version: CACHE_VERSION,
            head_sha: head_sha.to_string(),
            metadata: test_metadata(),
            diff_files: vec![],
            commits: vec![],
            comments: vec![],
        }
    }

    #[test]
    fn read_cache_returns_none_for_missing_file() {
        let id = PrId {
            owner: "nonexistent-owner-xyz".into(),
            repo: "nonexistent-repo-xyz".into(),
            number: 99999,
        };
        assert!(read_cache(&id, "example.com", "abc123").is_none());
    }

    #[test]
    fn write_and_read_cache_roundtrip() {
        let id = PrId {
            owner: "test-owner".into(),
            repo: "test-repo".into(),
            number: 10001,
        };
        let cache = test_cache("sha-abc123");

        write_cache(&id, "github.com", &cache).unwrap();
        let loaded = read_cache(&id, "github.com", "sha-abc123");

        assert!(loaded.is_some());
        let loaded = loaded.unwrap();
        assert_eq!(loaded.head_sha, "sha-abc123");
        assert_eq!(loaded.metadata.title, "Test PR");
        assert_eq!(loaded.version, CACHE_VERSION);

        // Cleanup
        let _ = std::fs::remove_file(cache_path(&id, "github.com"));
    }

    #[test]
    fn cache_miss_on_different_head_sha() {
        let id = PrId {
            owner: "test-owner".into(),
            repo: "test-repo".into(),
            number: 10002,
        };
        let cache = test_cache("sha-old");

        write_cache(&id, "github.com", &cache).unwrap();
        let loaded = read_cache(&id, "github.com", "sha-new");

        assert!(loaded.is_none());

        // Cleanup
        let _ = std::fs::remove_file(cache_path(&id, "github.com"));
    }

    #[test]
    fn cache_miss_on_different_version() {
        let id = PrId {
            owner: "test-owner".into(),
            repo: "test-repo".into(),
            number: 10003,
        };
        // Write a cache with a wrong version
        let cache = PrCache {
            version: CACHE_VERSION + 1,
            head_sha: "sha-abc".to_string(),
            metadata: test_metadata(),
            diff_files: vec![],
            commits: vec![],
            comments: vec![],
        };

        write_cache(&id, "github.com", &cache).unwrap();
        let loaded = read_cache(&id, "github.com", "sha-abc");

        assert!(loaded.is_none());

        // Cleanup
        let _ = std::fs::remove_file(cache_path(&id, "github.com"));
    }
}