use crate::error::RsGuardError;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const DEFAULT_CACHE_DIR: &str = ".rs-guard/cache";
const DEFAULT_TTL_SECS: u64 = 86400;
const DEFAULT_MAX_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const CACHE_FILE_EXT: &str = "cache";
static CACHE_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn find_git_root() -> Option<PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
Some(PathBuf::from(path.trim()))
} else {
None
}
}
fn default_cache_dir() -> PathBuf {
find_git_root()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.join(DEFAULT_CACHE_DIR)
}
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub cache_dir: PathBuf,
pub ttl: Duration,
pub enabled: bool,
pub max_size_bytes: u64,
pub auto_gitignore: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
cache_dir: default_cache_dir(),
ttl: Duration::from_secs(DEFAULT_TTL_SECS),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
}
}
}
#[derive(Debug, Clone)]
struct CacheKey {
diff_hash: String,
prompt_hash: String,
provider: String,
model: String,
temperature: f32,
}
impl CacheKey {
fn new(
diff_content: &str,
prompt: &str,
provider: &str,
model: &str,
temperature: f32,
) -> Self {
let diff_hash = hash_content(diff_content);
let prompt_hash = hash_content(prompt);
Self {
diff_hash,
prompt_hash,
provider: provider.to_string(),
model: model.to_string(),
temperature,
}
}
fn as_string(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.diff_hash.as_bytes());
hasher.update(self.prompt_hash.as_bytes());
hasher.update(self.provider.as_bytes());
hasher.update(self.model.as_bytes());
hasher.update(self.temperature.to_le_bytes());
hex::encode(hasher.finalize())
}
}
fn hash_content(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
pub fn diff_hash(content: &str) -> String {
hash_content(content)
}
#[derive(Debug, Clone)]
pub struct DiffCache {
config: CacheConfig,
}
impl DiffCache {
pub fn new(config: CacheConfig) -> Result<Self, RsGuardError> {
let cache = Self {
config: config.clone(),
};
if config.enabled {
cache.ensure_cache_dir()?;
}
Ok(cache)
}
fn cache_path(&self, key: &str) -> PathBuf {
self.config
.cache_dir
.join(format!("{}.{}", key, CACHE_FILE_EXT))
}
fn ensure_cache_dir(&self) -> Result<(), RsGuardError> {
fs::create_dir_all(&self.config.cache_dir)
.map_err(|e| RsGuardError::Config(format!("Failed to create cache dir: {}", e)))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn read_entry(&self, path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let newline_idx = content.find('\n')?;
let timestamp_str = &content[..newline_idx];
let timestamp: u64 = timestamp_str.parse().ok()?;
let now = Self::now_secs();
let age = now.saturating_sub(timestamp);
if age >= self.config.ttl.as_secs() {
let _ = fs::remove_file(path);
return None;
}
let response = &content[newline_idx + 1..];
if response.is_empty() {
return None;
}
Some(response.to_string())
}
pub fn get(
&self,
diff_content: &str,
prompt: &str,
provider: &str,
model: &str,
temperature: f32,
) -> Option<String> {
if !self.config.enabled {
return None;
}
let key = CacheKey::new(diff_content, prompt, provider, model, temperature);
let key_str = key.as_string();
let path = self.cache_path(&key_str);
if !path.exists() {
return None;
}
match self.read_entry(&path) {
Some(response) => {
log::debug!("Cache hit for cache key: {}", key_str);
Some(response)
}
None => {
log::debug!("Cache miss or expired entry for cache key: {}", key_str);
None
}
}
}
pub fn set(
&self,
diff_content: &str,
prompt: &str,
provider: &str,
model: &str,
temperature: f32,
response: &str,
) -> Result<(), RsGuardError> {
if !self.config.enabled {
return Ok(());
}
let key = CacheKey::new(diff_content, prompt, provider, model, temperature);
let key_str = key.as_string();
let path = self.cache_path(&key_str);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = CACHE_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_filename = format!("{}.{}.{}.tmp", key_str, timestamp, counter);
let tmp_path = self.config.cache_dir.join(&tmp_filename);
{
let mut tmp = fs::File::options()
.write(true)
.create_new(true)
.open(&tmp_path)?;
writeln!(tmp, "{}", Self::now_secs())?;
tmp.write_all(response.as_bytes())?;
tmp.sync_all()?;
}
fs::rename(&tmp_path, &path)?;
log::debug!("Cached response for cache key: {}", key_str);
self.enforce_size_limit()?;
Ok(())
}
fn total_size(&self) -> Result<u64, RsGuardError> {
let mut total = 0u64;
let entries = fs::read_dir(&self.config.cache_dir).map_err(|e| {
RsGuardError::Io(std::io::Error::other(format!(
"Failed to read cache dir: {}",
e
)))
})?;
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
total += metadata.len();
}
}
}
Ok(total)
}
fn enforce_size_limit(&self) -> Result<(), RsGuardError> {
let total = self.total_size()?;
if total <= self.config.max_size_bytes {
return Ok(());
}
log::warn!(
"Cache size {} bytes exceeds limit {} bytes, cleaning up",
total,
self.config.max_size_bytes
);
let mut files: Vec<(PathBuf, u64)> = Vec::new();
let entries = fs::read_dir(&self.config.cache_dir).map_err(|e| {
RsGuardError::Io(std::io::Error::other(format!(
"Failed to read cache dir: {}",
e
)))
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some(CACHE_FILE_EXT) {
if let Ok(content) = fs::read_to_string(&path) {
if let Some(first_line) = content.lines().next() {
if let Ok(timestamp) = first_line.parse::<u64>() {
files.push((path, timestamp));
}
}
}
}
}
files.sort_by_key(|a| a.1);
let mut current_size = total;
for (path, _) in files {
if current_size <= self.config.max_size_bytes {
break;
}
if let Ok(metadata) = fs::metadata(&path) {
let size = metadata.len();
if fs::remove_file(&path).is_ok() {
log::debug!("Removed old cache entry: {:?}", path);
current_size = current_size.saturating_sub(size);
}
}
}
Ok(())
}
pub fn ensure_gitignored(&self) -> Result<(), RsGuardError> {
if !self.config.enabled || !self.config.auto_gitignore {
return Ok(());
}
let gitignore_path = find_git_root()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.join(".gitignore");
let cache_dir_str = self.config.cache_dir.to_string_lossy();
let entry = format!("{}\n", cache_dir_str);
match fs::read_to_string(&gitignore_path) {
Ok(content) => {
let has_entry = content
.lines()
.any(|line| line == cache_dir_str || line == format!("{}/", cache_dir_str));
if has_entry {
return Ok(());
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(RsGuardError::Io(e));
}
}
let mut f = fs::OpenOptions::new()
.append(true)
.create(true)
.open(&gitignore_path)
.map_err(RsGuardError::Io)?;
f.write_all(entry.as_bytes()).map_err(RsGuardError::Io)?;
Ok(())
}
pub fn clear(&self) -> Result<(), RsGuardError> {
let entries = fs::read_dir(&self.config.cache_dir).map_err(|e| {
RsGuardError::Io(std::io::Error::other(format!(
"Failed to read cache dir: {}",
e
)))
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some(CACHE_FILE_EXT) {
if let Err(e) = fs::remove_file(&path) {
log::warn!("Failed to remove cache entry {:?}: {}", path, e);
}
}
}
Ok(())
}
pub fn stats(&self) -> Result<CacheStats, RsGuardError> {
let mut file_count = 0u64;
let mut total_size = 0u64;
let entries = fs::read_dir(&self.config.cache_dir).map_err(|e| {
RsGuardError::Io(std::io::Error::other(format!(
"Failed to read cache dir: {}",
e
)))
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some(CACHE_FILE_EXT) {
if let Ok(metadata) = entry.metadata() {
file_count += 1;
total_size += metadata.len();
}
}
}
Ok(CacheStats {
file_count,
total_size_bytes: total_size,
max_size_bytes: self.config.max_size_bytes,
})
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub file_count: u64,
pub total_size_bytes: u64,
pub max_size_bytes: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_diff_hash_consistent() {
let content = "diff --git a/f.rs b/f.rs";
let h1 = diff_hash(content);
let h2 = diff_hash(content);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn test_diff_hash_different() {
let h1 = diff_hash("content a");
let h2 = diff_hash("content b");
assert_ne!(h1, h2);
}
#[test]
fn test_cache_key_includes_all_parameters() {
let key1 = CacheKey::new("diff", "prompt", "deepseek", "model", 0.1);
let key2 = CacheKey::new("diff", "prompt", "deepseek", "model", 0.1);
let key3 = CacheKey::new("diff", "prompt", "deepseek", "model", 0.2);
let key4 = CacheKey::new("diff", "prompt", "openai", "model", 0.1);
assert_eq!(key1.as_string(), key2.as_string());
assert_ne!(key1.as_string(), key3.as_string());
assert_ne!(key1.as_string(), key4.as_string());
}
#[test]
#[serial_test::serial]
fn test_gitignore_auto_creation() {
let dir = tempdir().unwrap();
let cache_dir = Path::new(DEFAULT_CACHE_DIR);
let config = CacheConfig {
cache_dir: cache_dir.to_path_buf(),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
cache.ensure_gitignored().unwrap();
let gitignore_path = dir.path().join(".gitignore");
assert!(gitignore_path.exists());
let content = std::fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains(".rs-guard/cache"));
let line_count_before = content.lines().count();
cache.ensure_gitignored().unwrap();
let content_after = std::fs::read_to_string(&gitignore_path).unwrap();
let line_count_after = content_after.lines().count();
assert_eq!(line_count_before, line_count_after);
std::env::set_current_dir(original_dir).unwrap();
}
#[test]
#[serial_test::serial]
fn test_gitignore_exact_line_matching() {
let dir = tempdir().unwrap();
let cache_dir = Path::new(DEFAULT_CACHE_DIR);
let config = CacheConfig {
cache_dir: cache_dir.to_path_buf(),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let gitignore_path = dir.path().join(".gitignore");
std::fs::write(&gitignore_path, ".rs-guard/cache2/").unwrap();
cache.ensure_gitignored().unwrap();
let content = std::fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains(".rs-guard/cache"));
std::env::set_current_dir(original_dir).unwrap();
}
#[test]
#[serial_test::serial]
fn test_gitignore_auto_gitignore_false_skips_write() {
let dir = tempdir().unwrap();
let cache_dir = Path::new(DEFAULT_CACHE_DIR);
let config = CacheConfig {
cache_dir: cache_dir.to_path_buf(),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: false,
};
let cache = DiffCache::new(config).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
cache.ensure_gitignored().unwrap();
let gitignore_path = dir.path().join(".gitignore");
assert!(
!gitignore_path.exists(),
".gitignore should not be created when auto_gitignore=false"
);
std::env::set_current_dir(original_dir).unwrap();
}
#[test]
#[serial_test::serial]
fn test_gitignore_disabled_cache_skips_write() {
let dir = tempdir().unwrap();
let cache_dir = Path::new(DEFAULT_CACHE_DIR);
let config = CacheConfig {
cache_dir: cache_dir.to_path_buf(),
ttl: Duration::from_secs(3600),
enabled: false,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
cache.ensure_gitignored().unwrap();
let gitignore_path = dir.path().join(".gitignore");
assert!(
!gitignore_path.exists(),
".gitignore should not be created when cache is disabled"
);
std::env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_cache_disabled_never_hits() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: false,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set(
"test content",
"prompt",
"deepseek",
"model",
0.1,
"cached response",
)
.unwrap();
assert!(cache
.get("test content", "prompt", "deepseek", "model", 0.1)
.is_none());
}
#[test]
fn test_cache_set_get_roundtrip() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set(
"diff content",
"system prompt",
"deepseek",
"deepseek-v4-flash",
0.1,
"llm response",
)
.unwrap();
let result = cache.get(
"diff content",
"system prompt",
"deepseek",
"deepseek-v4-flash",
0.1,
);
assert_eq!(result, Some("llm response".to_string()));
}
#[test]
fn test_cache_miss_returns_none() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
assert!(cache
.get("nonexistent content", "prompt", "deepseek", "model", 0.1)
.is_none());
}
#[test]
fn test_cache_entry_expires() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(0), enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set(
"expiring content",
"prompt",
"deepseek",
"model",
0.1,
"will expire",
)
.unwrap();
let result = cache.get("expiring content", "prompt", "deepseek", "model", 0.1);
assert!(result.is_none());
let key = CacheKey::new("expiring content", "prompt", "deepseek", "model", 0.1).as_string();
assert!(!cache.cache_path(&key).exists());
}
#[test]
fn test_cache_directory_created() {
let dir = tempdir().unwrap();
let cache_dir = dir.path().join("custom/cache/path");
let config = CacheConfig {
cache_dir: cache_dir.clone(),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
DiffCache::new(config).unwrap();
assert!(cache_dir.exists());
}
#[test]
fn test_cache_set_overwrites_existing() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key", "prompt", "deepseek", "model", 0.1, "version 1")
.unwrap();
cache
.set("key", "prompt", "deepseek", "model", 0.1, "version 2")
.unwrap();
assert_eq!(
cache.get("key", "prompt", "deepseek", "model", 0.1),
Some("version 2".to_string())
);
}
#[test]
fn test_cache_size_limit_enforcement() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: 100, auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
for i in 0..10 {
cache
.set(
&format!("content {}", i),
"prompt",
"deepseek",
"model",
0.1,
&format!("response {}", i),
)
.unwrap();
}
let stats = cache.stats().unwrap();
assert!(stats.total_size_bytes <= 100);
}
#[test]
fn test_cache_clear() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key1", "prompt", "deepseek", "model", 0.1, "value1")
.unwrap();
cache
.set("key2", "prompt", "deepseek", "model", 0.1, "value2")
.unwrap();
let stats = cache.stats().unwrap();
assert_eq!(stats.file_count, 2);
cache.clear().unwrap();
let stats = cache.stats().unwrap();
assert_eq!(stats.file_count, 0);
}
#[test]
fn test_cache_stats() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: 1000,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key1", "prompt", "deepseek", "model", 0.1, "value1")
.unwrap();
cache
.set("key2", "prompt", "deepseek", "model", 0.1, "value2")
.unwrap();
let stats = cache.stats().unwrap();
assert_eq!(stats.file_count, 2);
assert!(stats.total_size_bytes > 0);
assert_eq!(stats.max_size_bytes, 1000);
}
#[test]
fn test_cache_multiline_response() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
let multiline = "line1\nline2\nline3\nline4";
cache
.set("key", "prompt", "deepseek", "model", 0.1, multiline)
.unwrap();
assert_eq!(
cache.get("key", "prompt", "deepseek", "model", 0.1),
Some(multiline.to_string())
);
}
#[test]
fn test_cache_corrupted_file_no_timestamp() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key", "prompt", "deepseek", "model", 0.1, "response")
.unwrap();
let key = CacheKey::new("key", "prompt", "deepseek", "model", 0.1).as_string();
let path = cache.cache_path(&key);
std::fs::write(&path, "not a valid cache entry").unwrap();
assert!(cache
.get("key", "prompt", "deepseek", "model", 0.1)
.is_none());
}
#[test]
fn test_cache_corrupted_file_invalid_timestamp() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key2", "prompt", "deepseek", "model", 0.1, "response")
.unwrap();
let key = CacheKey::new("key2", "prompt", "deepseek", "model", 0.1).as_string();
let path = cache.cache_path(&key);
std::fs::write(&path, "not-a-number\nresponse body").unwrap();
assert!(cache
.get("key2", "prompt", "deepseek", "model", 0.1)
.is_none());
}
#[test]
fn test_cache_corrupted_file_empty() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key3", "prompt", "deepseek", "model", 0.1, "response")
.unwrap();
let key = CacheKey::new("key3", "prompt", "deepseek", "model", 0.1).as_string();
let path = cache.cache_path(&key);
std::fs::write(&path, "").unwrap();
assert!(cache
.get("key3", "prompt", "deepseek", "model", 0.1)
.is_none());
}
#[test]
fn test_cache_corrupted_file_binary_data() {
let dir = tempdir().unwrap();
let config = CacheConfig {
cache_dir: dir.path().join(".rs-guard/cache"),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
cache
.set("key4", "prompt", "deepseek", "model", 0.1, "response")
.unwrap();
let key = CacheKey::new("key4", "prompt", "deepseek", "model", 0.1).as_string();
let path = cache.cache_path(&key);
std::fs::write(&path, [0xFF, 0xFE, 0x00, 0x01, 0x02]).unwrap();
assert!(cache
.get("key4", "prompt", "deepseek", "model", 0.1)
.is_none());
}
#[test]
#[serial_test::serial]
fn test_find_git_root_in_git_repo() {
let root = find_git_root();
assert!(root.is_some(), "should find git root in a git repository");
let root = root.unwrap();
assert!(root.exists(), "git root should exist");
assert!(root.join(".git").exists() || root.join(".git").is_symlink());
}
#[test]
#[serial_test::serial]
fn test_default_cache_dir_uses_git_root() {
let cache_dir = default_cache_dir();
assert!(
cache_dir.to_string_lossy().ends_with(".rs-guard/cache"),
"cache dir should end with .rs-guard/cache, got: {:?}",
cache_dir
);
}
#[test]
fn test_cache_config_with_custom_dir() {
let dir = tempdir().unwrap();
let custom_dir = dir.path().join("custom/cache");
let config = CacheConfig {
cache_dir: custom_dir.clone(),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
assert!(custom_dir.exists(), "custom cache dir should be created");
cache
.set("key", "prompt", "deepseek", "model", 0.1, "value")
.unwrap();
let result = cache.get("key", "prompt", "deepseek", "model", 0.1);
assert_eq!(result, Some("value".to_string()));
}
#[test]
#[serial_test::serial]
#[cfg(unix)]
fn test_gitignore_readonly_returns_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let cache_dir = dir.path().join(DEFAULT_CACHE_DIR);
let config = CacheConfig {
cache_dir: cache_dir.to_path_buf(),
ttl: Duration::from_secs(3600),
enabled: true,
max_size_bytes: DEFAULT_MAX_SIZE_BYTES,
auto_gitignore: true,
};
let cache = DiffCache::new(config).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let gitignore_path = dir.path().join(".gitignore");
std::fs::write(&gitignore_path, "existing\n").unwrap();
std::fs::set_permissions(&gitignore_path, std::fs::Permissions::from_mode(0o444)).unwrap();
let result = cache.ensure_gitignored();
assert!(result.is_err(), "should fail on read-only .gitignore");
std::fs::set_permissions(&gitignore_path, std::fs::Permissions::from_mode(0o644)).unwrap();
std::env::set_current_dir(original_dir).unwrap();
}
}