1use anyhow::{Context, Result};
2use chrono::{DateTime, Duration, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tokio_rusqlite::{params, Connection};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CachedGitStatus {
10 pub repository_name: String,
11 pub path: PathBuf,
12 pub branch: Option<String>,
13 pub clean: bool,
14 pub ahead: usize,
15 pub behind: usize,
16 pub staged: usize,
17 pub unstaged: usize,
18 pub untracked: usize,
19 pub remote_url: Option<String>,
20 pub last_updated: DateTime<Utc>,
21}
22
23impl From<crate::workspace::operations::GitStatus> for CachedGitStatus {
24 fn from(status: crate::workspace::operations::GitStatus) -> Self {
25 Self {
26 repository_name: status.repository_name,
27 path: PathBuf::from(status.path),
28 branch: status.branch,
29 clean: status.clean,
30 ahead: status.ahead,
31 behind: status.behind,
32 staged: status.staged,
33 unstaged: status.unstaged,
34 untracked: status.untracked,
35 remote_url: status.remote_url,
36 last_updated: Utc::now(),
37 }
38 }
39}
40
41impl From<CachedGitStatus> for crate::workspace::operations::GitStatus {
42 fn from(cached: CachedGitStatus) -> Self {
43 Self {
44 repository_name: cached.repository_name,
45 path: cached.path.to_string_lossy().to_string(),
46 branch: cached.branch,
47 clean: cached.clean,
48 ahead: cached.ahead,
49 behind: cached.behind,
50 staged: cached.staged,
51 unstaged: cached.unstaged,
52 untracked: cached.untracked,
53 remote_url: cached.remote_url,
54 }
55 }
56}
57
58pub struct GitStatusCache {
60 db_path: PathBuf,
61 cache_ttl_minutes: i64,
63}
64
65impl GitStatusCache {
66 pub fn new<P: Into<PathBuf>>(db_path: P) -> Self {
68 Self {
69 db_path: db_path.into(),
70 cache_ttl_minutes: 5, }
72 }
73
74 #[allow(dead_code)]
76 pub fn with_ttl<P: Into<PathBuf>>(db_path: P, ttl_minutes: i64) -> Self {
77 Self {
78 db_path: db_path.into(),
79 cache_ttl_minutes: ttl_minutes,
80 }
81 }
82
83 pub async fn initialize(&self) -> Result<()> {
85 let conn = Connection::open(&self.db_path)
86 .await
87 .context("Failed to open git status cache database")?;
88
89 conn.call(move |conn| {
90 conn.execute(
91 r#"
92 CREATE TABLE IF NOT EXISTS git_status (
93 repository_name TEXT PRIMARY KEY,
94 path TEXT NOT NULL,
95 branch TEXT,
96 clean INTEGER NOT NULL,
97 ahead INTEGER NOT NULL,
98 behind INTEGER NOT NULL,
99 staged INTEGER NOT NULL,
100 unstaged INTEGER NOT NULL,
101 untracked INTEGER NOT NULL,
102 remote_url TEXT,
103 last_updated TEXT NOT NULL
104 )
105 "#,
106 [],
107 )?;
108
109 conn.execute(
111 "CREATE INDEX IF NOT EXISTS idx_git_status_path ON git_status(path)",
112 [],
113 )?;
114
115 conn.execute(
116 "CREATE INDEX IF NOT EXISTS idx_git_status_last_updated ON git_status(last_updated)",
117 [],
118 )?;
119
120 Ok(())
121 })
122 .await
123 .context("Failed to initialize git status cache tables")?;
124
125 Ok(())
126 }
127
128 #[allow(dead_code)]
130 pub async fn cache_git_status(&self, status: &CachedGitStatus) -> Result<()> {
131 let conn = Connection::open(&self.db_path).await?;
132 let status = status.clone();
133
134 conn.call(move |conn| {
135 let last_updated = status.last_updated.to_rfc3339();
136
137 conn.execute(
138 r#"
139 INSERT OR REPLACE INTO git_status
140 (repository_name, path, branch, clean, ahead, behind, staged, unstaged, untracked, remote_url, last_updated)
141 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
142 "#,
143 params![
144 status.repository_name,
145 status.path.to_string_lossy(),
146 status.branch,
147 status.clean as i32,
148 status.ahead as i64,
149 status.behind as i64,
150 status.staged as i64,
151 status.unstaged as i64,
152 status.untracked as i64,
153 status.remote_url,
154 last_updated
155 ],
156 )?;
157
158 Ok(())
159 })
160 .await
161 .context("Failed to cache git status")?;
162
163 Ok(())
164 }
165
166 #[allow(dead_code)]
168 pub async fn get_git_status(&self, repository_name: &str) -> Result<Option<CachedGitStatus>> {
169 let conn = Connection::open(&self.db_path).await?;
170 let repo_name = repository_name.to_string();
171 let ttl_minutes = self.cache_ttl_minutes;
172
173 let result = conn
174 .call(move |conn| {
175 let mut stmt = conn.prepare(
176 r#"
177 SELECT repository_name, path, branch, clean, ahead, behind, staged, unstaged, untracked, remote_url, last_updated
178 FROM git_status
179 WHERE repository_name = ?1
180 "#
181 )?;
182
183 let status = stmt.query_row(params![repo_name], |row| {
184 let last_updated_str: String = row.get(10)?;
185 let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
186 .map_err(|e| rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e)))?
187 .with_timezone(&Utc);
188
189 Ok(CachedGitStatus {
190 repository_name: row.get(0)?,
191 path: PathBuf::from(row.get::<_, String>(1)?),
192 branch: row.get(2)?,
193 clean: row.get::<_, i32>(3)? != 0,
194 ahead: row.get::<_, i64>(4)? as usize,
195 behind: row.get::<_, i64>(5)? as usize,
196 staged: row.get::<_, i64>(6)? as usize,
197 unstaged: row.get::<_, i64>(7)? as usize,
198 untracked: row.get::<_, i64>(8)? as usize,
199 remote_url: row.get(9)?,
200 last_updated,
201 })
202 });
203
204 match status {
205 Ok(status) => {
206 let now = Utc::now();
208 let age = now.signed_duration_since(status.last_updated);
209 if age <= Duration::minutes(ttl_minutes) {
210 Ok(Some(status))
211 } else {
212 Ok(None)
214 }
215 }
216 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
217 Err(e) => Err(tokio_rusqlite::Error::Rusqlite(e)),
218 }
219 })
220 .await
221 .context("Failed to get cached git status")?;
222
223 Ok(result)
224 }
225
226 pub async fn get_all_git_statuses(&self) -> Result<Vec<CachedGitStatus>> {
228 let conn = Connection::open(&self.db_path).await?;
229 let ttl_minutes = self.cache_ttl_minutes;
230
231 let statuses = conn
232 .call(move |conn| {
233 let mut stmt = conn.prepare(
234 r#"
235 SELECT repository_name, path, branch, clean, ahead, behind, staged, unstaged, untracked, remote_url, last_updated
236 FROM git_status
237 ORDER BY repository_name
238 "#
239 )?;
240
241 let status_iter = stmt.query_map([], |row| {
242 let last_updated_str: String = row.get(10)?;
243 let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
244 .map_err(|e| rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e)))?
245 .with_timezone(&Utc);
246
247 Ok(CachedGitStatus {
248 repository_name: row.get(0)?,
249 path: PathBuf::from(row.get::<_, String>(1)?),
250 branch: row.get(2)?,
251 clean: row.get::<_, i32>(3)? != 0,
252 ahead: row.get::<_, i64>(4)? as usize,
253 behind: row.get::<_, i64>(5)? as usize,
254 staged: row.get::<_, i64>(6)? as usize,
255 unstaged: row.get::<_, i64>(7)? as usize,
256 untracked: row.get::<_, i64>(8)? as usize,
257 remote_url: row.get(9)?,
258 last_updated,
259 })
260 })?;
261
262 let mut statuses = Vec::new();
263 let now = Utc::now();
264 for status_result in status_iter {
265 let status = status_result?;
266 let age = now.signed_duration_since(status.last_updated);
268 if age <= Duration::minutes(ttl_minutes) {
269 statuses.push(status);
270 }
271 }
272
273 Ok(statuses)
274 })
275 .await
276 .context("Failed to get all cached git statuses")?;
277
278 Ok(statuses)
279 }
280
281 pub async fn cleanup_expired(&self) -> Result<usize> {
283 let conn = Connection::open(&self.db_path).await?;
284 let ttl_minutes = self.cache_ttl_minutes;
285
286 let deleted_count = conn
287 .call(move |conn| {
288 let cutoff_time = Utc::now() - Duration::minutes(ttl_minutes);
289 let cutoff_str = cutoff_time.to_rfc3339();
290
291 let result = conn.execute(
292 "DELETE FROM git_status WHERE last_updated < ?1",
293 params![cutoff_str],
294 )?;
295
296 Ok(result)
297 })
298 .await
299 .context("Failed to cleanup expired git status cache entries")?;
300
301 Ok(deleted_count)
302 }
303
304 pub async fn invalidate_repository(&self, repository_name: &str) -> Result<()> {
306 let conn = Connection::open(&self.db_path).await?;
307 let repo_name = repository_name.to_string();
308
309 conn.call(move |conn| {
310 conn.execute(
311 "DELETE FROM git_status WHERE repository_name = ?1",
312 params![repo_name],
313 )?;
314 Ok(())
315 })
316 .await
317 .context("Failed to invalidate repository cache")?;
318
319 Ok(())
320 }
321
322 pub async fn get_stats(&self) -> Result<GitCacheStats> {
324 let conn = Connection::open(&self.db_path).await?;
325 let ttl_minutes = self.cache_ttl_minutes;
326
327 let stats = conn
328 .call(move |conn| {
329 let total_entries: i64 =
330 conn.query_row("SELECT COUNT(*) FROM git_status", [], |row| row.get(0))?;
331
332 let cutoff_time = Utc::now() - Duration::minutes(ttl_minutes);
333 let cutoff_str = cutoff_time.to_rfc3339();
334
335 let valid_entries: i64 = conn.query_row(
336 "SELECT COUNT(*) FROM git_status WHERE last_updated >= ?1",
337 params![cutoff_str],
338 |row| row.get(0),
339 )?;
340
341 let expired_entries = total_entries - valid_entries;
342
343 Ok::<_, tokio_rusqlite::Error>(GitCacheStats {
344 total_entries: total_entries as usize,
345 valid_entries: valid_entries as usize,
346 expired_entries: expired_entries as usize,
347 ttl_minutes: ttl_minutes as usize,
348 })
349 })
350 .await
351 .context("Failed to get git cache statistics")?;
352
353 Ok(stats)
354 }
355}
356
357#[derive(Debug, Clone)]
359pub struct GitCacheStats {
360 pub total_entries: usize,
361 pub valid_entries: usize,
362 pub expired_entries: usize,
363 pub ttl_minutes: usize,
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use tempfile::tempdir;
370
371 #[tokio::test]
372 async fn test_git_status_cache_operations() {
373 let temp_dir = tempdir().unwrap();
374 let db_path = temp_dir.path().join("test_git_status.db");
375
376 let cache = GitStatusCache::new(db_path);
377 cache.initialize().await.unwrap();
378
379 let status = CachedGitStatus {
381 repository_name: "test-repo".to_string(),
382 path: PathBuf::from("/path/to/repo"),
383 branch: Some("main".to_string()),
384 clean: false,
385 ahead: 2,
386 behind: 1,
387 staged: 3,
388 unstaged: 1,
389 untracked: 2,
390 remote_url: Some("https://github.com/user/repo.git".to_string()),
391 last_updated: Utc::now(),
392 };
393
394 cache.cache_git_status(&status).await.unwrap();
395
396 let cached = cache.get_git_status("test-repo").await.unwrap();
398 assert!(cached.is_some());
399
400 let cached = cached.unwrap();
401 assert_eq!(cached.repository_name, "test-repo");
402 assert_eq!(cached.branch, Some("main".to_string()));
403 assert_eq!(cached.ahead, 2);
404 assert_eq!(cached.behind, 1);
405 assert!(!cached.clean);
406 }
407
408 #[tokio::test]
409 async fn test_cache_ttl_expiration() {
410 let temp_dir = tempdir().unwrap();
411 let db_path = temp_dir.path().join("test_ttl.db");
412
413 let cache = GitStatusCache::with_ttl(db_path, 0); cache.initialize().await.unwrap();
416
417 let status = CachedGitStatus {
418 repository_name: "test-repo".to_string(),
419 path: PathBuf::from("/path/to/repo"),
420 branch: Some("main".to_string()),
421 clean: true,
422 ahead: 0,
423 behind: 0,
424 staged: 0,
425 unstaged: 0,
426 untracked: 0,
427 remote_url: None,
428 last_updated: Utc::now() - Duration::minutes(1), };
430
431 cache.cache_git_status(&status).await.unwrap();
432
433 let cached = cache.get_git_status("test-repo").await.unwrap();
435 assert!(cached.is_none());
436 }
437}