use crate::error::CoraError;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::debug;
use crate::engine::types::ReviewResponse;
fn cache_dir() -> std::result::Result<PathBuf, CoraError> {
let home = dirs::home_dir()
.ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?;
Ok(home.join(".cache").join("cora").join("reviews"))
}
#[allow(clippy::format_collect)]
fn cache_key(diff: &str, model: &str, temperature: f32) -> String {
let mut hasher = Sha256::new();
hasher.update(diff.as_bytes());
hasher.update(model.as_bytes());
hasher.update(temperature.to_le_bytes());
let result = hasher.finalize();
result.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn get_cached_review(
diff: &str,
model: &str,
temperature: f32,
ttl: u64,
) -> Option<ReviewResponse> {
let hash = cache_key(diff, model, temperature);
let dir = cache_dir().ok()?;
let path = dir.join(format!("{hash}.json"));
if !path.is_file() {
debug!("cache miss: file not found");
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let cached: CachedReview = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
debug!("cache corrupt, ignoring: {}", e);
return None;
}
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_secs = now.saturating_sub(cached.timestamp);
let ttl_secs = ttl * 60;
if age_secs > ttl_secs {
debug!(age_secs = age_secs, ttl_secs = ttl_secs, "cache expired");
let _ = std::fs::remove_file(&path);
return None;
}
debug!(hash = %hash, age_secs = age_secs, "cache hit");
Some(cached.response)
}
pub fn save_cached_review(
diff: &str,
model: &str,
temperature: f32,
response: &ReviewResponse,
) -> std::result::Result<(), CoraError> {
let dir = cache_dir()?;
std::fs::create_dir_all(&dir).map_err(CoraError::CacheIo)?;
let hash = cache_key(diff, model, temperature);
let path = dir.join(format!("{hash}.json"));
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached = CachedReview {
response: response.clone(),
timestamp: now,
};
let json = serde_json::to_string_pretty(&cached)
.map_err(|e| CoraError::CacheSerialize(e.to_string()))?;
std::fs::write(&path, json).map_err(CoraError::CacheIo)?;
debug!(hash = %hash, "saved review to cache");
Ok(())
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedReview {
response: ReviewResponse,
timestamp: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::Severity;
use crate::engine::types::ReviewIssue;
fn make_response() -> ReviewResponse {
ReviewResponse {
issues: vec![ReviewIssue {
file: "src/main.rs".to_string(),
line: Some(10),
severity: Severity::Major,
issue_type: Some("bugs".to_string()),
title: "Null pointer".to_string(),
body: "Could be null here".to_string(),
suggested_fix: Some("Add a check".to_string()),
}],
summary: "Found 1 issue.".to_string(),
tokens_used: None,
should_block: false,
}
}
#[test]
fn cache_key_is_deterministic() {
let hash1 = cache_key("hello world", "gpt-4", 0.0);
let hash2 = cache_key("hello world", "gpt-4", 0.0);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 64); }
#[test]
fn cache_key_differs_for_different_inputs() {
let hash1 = cache_key("hello world", "gpt-4", 0.0);
let hash2 = cache_key("hello earth", "gpt-4", 0.0);
assert_ne!(hash1, hash2);
}
#[test]
fn cache_key_includes_model_and_temperature() {
let h1 = cache_key("diff", "gpt-4", 0.0);
let h2 = cache_key("diff", "gpt-3.5", 0.0);
let h3 = cache_key("diff", "gpt-4", 0.7);
assert_ne!(h1, h2, "different models should differ");
assert_ne!(h1, h3, "different temperatures should differ");
}
#[test]
fn cache_key_len_is_64() {
let diff = "diff --git a/file.txt b/file.txt\n+ hello";
let hash = cache_key(diff, "model", 0.0);
assert_eq!(hash.len(), 64);
}
#[test]
fn cache_miss_on_different_diff() {
let diff1 = "diff --git a/a.txt b/a.txt\n+ hello";
let diff2 = "diff --git a/b.txt b/b.txt\n+ world";
let hash1 = cache_key(diff1, "model", 0.0);
let hash2 = cache_key(diff2, "model", 0.0);
assert_ne!(hash1, hash2);
}
#[test]
fn cached_review_serialization_roundtrip() {
let response = make_response();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached = CachedReview {
response,
timestamp: now,
};
let json = serde_json::to_string(&cached).unwrap();
let back: CachedReview = serde_json::from_str(&json).unwrap();
assert_eq!(back.timestamp, now);
assert_eq!(back.response.issues.len(), 1);
assert_eq!(back.response.issues[0].file, "src/main.rs");
assert_eq!(back.response.summary, "Found 1 issue.");
}
#[test]
fn ttl_expiry() {
let response = make_response();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached = CachedReview {
response,
timestamp: now.saturating_sub(2 * 60 * 60), };
let json = serde_json::to_string(&cached).unwrap();
let back: CachedReview = serde_json::from_str(&json).unwrap();
let age_secs = now - back.timestamp;
let ttl_secs = 60 * 60;
assert!(age_secs > ttl_secs);
}
#[test]
fn ttl_not_expired() {
let response = make_response();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached = CachedReview {
response,
timestamp: now.saturating_sub(10 * 60), };
let json = serde_json::to_string(&cached).unwrap();
let back: CachedReview = serde_json::from_str(&json).unwrap();
let age_secs = now - back.timestamp;
let ttl_secs = 1440 * 60;
assert!(age_secs <= ttl_secs);
}
#[test]
fn save_and_get_cached_review() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let diff = "test diff content";
let hash = cache_key(diff, "model", 0.0);
let path = dir.join(format!("{hash}.json"));
let response = make_response();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached = CachedReview {
response: response.clone(),
timestamp: now,
};
let json = serde_json::to_string(&cached).unwrap();
std::fs::write(&path, json).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let back: CachedReview = serde_json::from_str(&content).unwrap();
assert_eq!(back.response.issues.len(), 1);
assert_eq!(back.response.issues[0].title, "Null pointer");
}
}