project_rag/
git_cache.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Cache for indexed git commits to support incremental updates
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct GitCache {
10    /// Map of repository path -> set of indexed commit hashes
11    pub repos: HashMap<String, HashSet<String>>,
12}
13
14impl GitCache {
15    /// Get the default cache file path
16    pub fn default_path() -> PathBuf {
17        crate::paths::PlatformPaths::default_git_cache_path()
18    }
19
20    /// Load cache from disk
21    pub fn load(cache_path: &Path) -> Result<Self> {
22        if !cache_path.exists() {
23            tracing::debug!("Git cache file not found, starting with empty cache");
24            return Ok(Self::default());
25        }
26
27        let content = fs::read_to_string(cache_path).context("Failed to read git cache file")?;
28
29        let cache: GitCache =
30            serde_json::from_str(&content).context("Failed to parse git cache file")?;
31
32        tracing::info!(
33            "Loaded git cache with {} indexed repositories",
34            cache.repos.len()
35        );
36        Ok(cache)
37    }
38
39    /// Save cache to disk
40    pub fn save(&self, cache_path: &Path) -> Result<()> {
41        // Create parent directory if it doesn't exist
42        if let Some(parent) = cache_path.parent() {
43            fs::create_dir_all(parent).context("Failed to create git cache directory")?;
44        }
45
46        let content =
47            serde_json::to_string_pretty(self).context("Failed to serialize git cache")?;
48
49        fs::write(cache_path, content).context("Failed to write git cache file")?;
50
51        tracing::debug!("Saved git cache to {:?}", cache_path);
52        Ok(())
53    }
54
55    /// Check if a commit is already indexed
56    pub fn has_commit(&self, repo_path: &str, commit_hash: &str) -> bool {
57        self.repos
58            .get(repo_path)
59            .map(|commits| commits.contains(commit_hash))
60            .unwrap_or(false)
61    }
62
63    /// Get all indexed commits for a repository
64    pub fn get_repo(&self, repo_path: &str) -> Option<&HashSet<String>> {
65        self.repos.get(repo_path)
66    }
67
68    /// Get the count of indexed commits for a repository
69    pub fn commit_count(&self, repo_path: &str) -> usize {
70        self.repos
71            .get(repo_path)
72            .map(|commits| commits.len())
73            .unwrap_or(0)
74    }
75
76    /// Add indexed commits for a repository
77    pub fn add_commits(&mut self, repo_path: String, commit_hashes: HashSet<String>) {
78        self.repos
79            .entry(repo_path)
80            .or_default()
81            .extend(commit_hashes);
82    }
83
84    /// Update commits for a repository (replaces existing)
85    pub fn update_repo(&mut self, repo_path: String, commit_hashes: HashSet<String>) {
86        self.repos.insert(repo_path, commit_hashes);
87    }
88
89    /// Remove a repository from cache
90    pub fn remove_repo(&mut self, repo_path: &str) -> bool {
91        self.repos.remove(repo_path).is_some()
92    }
93
94    /// Clear all cached repositories
95    pub fn clear(&mut self) {
96        self.repos.clear();
97    }
98
99    /// Get total number of indexed commits across all repos
100    pub fn total_commits(&self) -> usize {
101        self.repos.values().map(|commits| commits.len()).sum()
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use tempfile::tempdir;
109
110    #[test]
111    fn test_default() {
112        let cache = GitCache::default();
113        assert_eq!(cache.repos.len(), 0);
114    }
115
116    #[test]
117    fn test_has_commit() {
118        let mut cache = GitCache::default();
119        let mut commits = HashSet::new();
120        commits.insert("abc123".to_string());
121        cache.repos.insert("/repo/path".to_string(), commits);
122
123        assert!(cache.has_commit("/repo/path", "abc123"));
124        assert!(!cache.has_commit("/repo/path", "def456"));
125        assert!(!cache.has_commit("/other/path", "abc123"));
126    }
127
128    #[test]
129    fn test_add_commits() {
130        let mut cache = GitCache::default();
131        let mut commits = HashSet::new();
132        commits.insert("abc123".to_string());
133
134        cache.add_commits("/repo/path".to_string(), commits);
135        assert_eq!(cache.commit_count("/repo/path"), 1);
136
137        let mut more_commits = HashSet::new();
138        more_commits.insert("def456".to_string());
139        cache.add_commits("/repo/path".to_string(), more_commits);
140        assert_eq!(cache.commit_count("/repo/path"), 2);
141    }
142
143    #[test]
144    fn test_update_repo() {
145        let mut cache = GitCache::default();
146        let mut commits1 = HashSet::new();
147        commits1.insert("abc123".to_string());
148        cache.add_commits("/repo/path".to_string(), commits1);
149
150        let mut commits2 = HashSet::new();
151        commits2.insert("def456".to_string());
152        cache.update_repo("/repo/path".to_string(), commits2);
153
154        assert_eq!(cache.commit_count("/repo/path"), 1);
155        assert!(!cache.has_commit("/repo/path", "abc123"));
156        assert!(cache.has_commit("/repo/path", "def456"));
157    }
158
159    #[test]
160    fn test_remove_repo() {
161        let mut cache = GitCache::default();
162        let mut commits = HashSet::new();
163        commits.insert("abc123".to_string());
164        cache.add_commits("/repo/path".to_string(), commits);
165
166        assert!(cache.remove_repo("/repo/path"));
167        assert!(!cache.remove_repo("/repo/path"));
168        assert_eq!(cache.commit_count("/repo/path"), 0);
169    }
170
171    #[test]
172    fn test_clear() {
173        let mut cache = GitCache::default();
174        let mut commits = HashSet::new();
175        commits.insert("abc123".to_string());
176        cache.add_commits("/repo1".to_string(), commits.clone());
177        cache.add_commits("/repo2".to_string(), commits);
178
179        cache.clear();
180        assert_eq!(cache.repos.len(), 0);
181        assert_eq!(cache.total_commits(), 0);
182    }
183
184    #[test]
185    fn test_total_commits() {
186        let mut cache = GitCache::default();
187        let mut commits1 = HashSet::new();
188        commits1.insert("abc123".to_string());
189        commits1.insert("abc124".to_string());
190
191        let mut commits2 = HashSet::new();
192        commits2.insert("def456".to_string());
193
194        cache.add_commits("/repo1".to_string(), commits1);
195        cache.add_commits("/repo2".to_string(), commits2);
196
197        assert_eq!(cache.total_commits(), 3);
198    }
199
200    #[test]
201    fn test_save_load() {
202        let dir = tempdir().unwrap();
203        let cache_path = dir.path().join("git_cache.json");
204
205        let mut cache = GitCache::default();
206        let mut commits = HashSet::new();
207        commits.insert("abc123".to_string());
208        commits.insert("def456".to_string());
209        cache.add_commits("/repo/path".to_string(), commits);
210
211        cache.save(&cache_path).unwrap();
212        assert!(cache_path.exists());
213
214        let loaded = GitCache::load(&cache_path).unwrap();
215        assert_eq!(loaded.commit_count("/repo/path"), 2);
216        assert!(loaded.has_commit("/repo/path", "abc123"));
217        assert!(loaded.has_commit("/repo/path", "def456"));
218    }
219
220    #[test]
221    fn test_load_nonexistent() {
222        let dir = tempdir().unwrap();
223        let cache_path = dir.path().join("nonexistent.json");
224
225        let cache = GitCache::load(&cache_path).unwrap();
226        assert_eq!(cache.repos.len(), 0);
227    }
228
229    #[test]
230    fn test_save_creates_directory() {
231        let dir = tempdir().unwrap();
232        let cache_path = dir.path().join("subdir/git_cache.json");
233
234        let cache = GitCache::default();
235        cache.save(&cache_path).unwrap();
236        assert!(cache_path.exists());
237    }
238}