1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tokio_rusqlite::{params, Connection};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CachedRepository {
10 pub name: String,
11 pub path: PathBuf,
12 pub configured_apps: Vec<String>,
13 pub last_updated: DateTime<Utc>,
14 pub path_exists: bool,
15 pub is_git_repo: bool,
16}
17
18pub struct RepositoryCache {
20 db_path: PathBuf,
21}
22
23impl RepositoryCache {
24 pub fn new<P: Into<PathBuf>>(db_path: P) -> Self {
26 Self {
27 db_path: db_path.into(),
28 }
29 }
30
31 pub async fn initialize(&self) -> Result<()> {
33 let conn = Connection::open(&self.db_path)
34 .await
35 .context("Failed to open repository cache database")?;
36
37 conn.call(move |conn| {
38 let has_old_schema = conn
40 .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='repositories'")
41 .and_then(|mut stmt| {
42 stmt.query_row([], |row| {
43 let sql: String = row.get(0)?;
44 Ok(sql.contains("exists INTEGER"))
45 })
46 })
47 .unwrap_or(false);
48
49 if has_old_schema {
50 conn.execute("DROP TABLE IF EXISTS repositories", [])?;
52 }
53
54 conn.execute(
55 r#"
56 CREATE TABLE IF NOT EXISTS repositories (
57 name TEXT PRIMARY KEY,
58 path TEXT NOT NULL,
59 configured_apps TEXT NOT NULL, -- JSON array of app names
60 last_updated TEXT NOT NULL, -- ISO 8601 datetime
61 path_exists INTEGER NOT NULL, -- boolean as integer
62 is_git_repo INTEGER NOT NULL -- boolean as integer
63 )
64 "#,
65 [],
66 )?;
67
68 conn.execute(
70 "CREATE INDEX IF NOT EXISTS idx_repositories_path ON repositories(path)",
71 [],
72 )?;
73
74 conn.execute(
75 "CREATE INDEX IF NOT EXISTS idx_repositories_last_updated ON repositories(last_updated)",
76 [],
77 )?;
78
79 Ok(())
80 })
81 .await
82 .context("Failed to initialize repository cache tables")?;
83
84 Ok(())
85 }
86
87 pub async fn cache_repository(&self, repo: &CachedRepository) -> Result<()> {
89 let conn = Connection::open(&self.db_path).await?;
90 let repo = repo.clone();
91
92 conn.call(move |conn| {
93 let apps_json = serde_json::to_string(&repo.configured_apps)
94 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
95 let last_updated = repo.last_updated.to_rfc3339();
96
97 conn.execute(
98 r#"
99 INSERT OR REPLACE INTO repositories
100 (name, path, configured_apps, last_updated, path_exists, is_git_repo)
101 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
102 "#,
103 params![
104 repo.name,
105 repo.path.to_string_lossy(),
106 apps_json,
107 last_updated,
108 repo.path_exists as i32,
109 repo.is_git_repo as i32
110 ],
111 )?;
112
113 Ok(())
114 })
115 .await
116 .context("Failed to cache repository information")?;
117
118 Ok(())
119 }
120
121 pub async fn get_repository(&self, name: &str) -> Result<Option<CachedRepository>> {
123 let conn = Connection::open(&self.db_path).await?;
124 let name = name.to_string();
125
126 let result = conn
127 .call(move |conn| {
128 let mut stmt = conn.prepare(
129 "SELECT name, path, configured_apps, last_updated, path_exists, is_git_repo
130 FROM repositories WHERE name = ?1",
131 )?;
132
133 let repo = stmt.query_row(params![name], |row| {
134 let apps_json: String = row.get(2)?;
135 let configured_apps: Vec<String> =
136 serde_json::from_str(&apps_json).map_err(|e| {
137 rusqlite::Error::FromSqlConversionFailure(
138 2,
139 rusqlite::types::Type::Text,
140 Box::new(e),
141 )
142 })?;
143
144 let last_updated_str: String = row.get(3)?;
145 let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
146 .map_err(|e| {
147 rusqlite::Error::FromSqlConversionFailure(
148 3,
149 rusqlite::types::Type::Text,
150 Box::new(e),
151 )
152 })?
153 .with_timezone(&Utc);
154
155 Ok(CachedRepository {
156 name: row.get(0)?,
157 path: PathBuf::from(row.get::<_, String>(1)?),
158 configured_apps,
159 last_updated,
160 path_exists: row.get::<_, i32>(4)? != 0,
161 is_git_repo: row.get::<_, i32>(5)? != 0,
162 })
163 });
164
165 match repo {
166 Ok(repo) => Ok(Some(repo)),
167 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
168 Err(e) => Err(tokio_rusqlite::Error::Rusqlite(e)),
169 }
170 })
171 .await
172 .context("Failed to get cached repository")?;
173
174 Ok(result)
175 }
176
177 pub async fn get_repositories_with_apps(&self) -> Result<Vec<CachedRepository>> {
179 let conn = Connection::open(&self.db_path).await?;
180
181 let repositories = conn
182 .call(move |conn| {
183 let mut stmt = conn.prepare(
184 r#"
185 SELECT name, path, configured_apps, last_updated, path_exists, is_git_repo
186 FROM repositories
187 WHERE json_array_length(configured_apps) > 0
188 AND path_exists = 1
189 ORDER BY name
190 "#,
191 )?;
192
193 let repo_iter = stmt.query_map([], |row| {
194 let apps_json: String = row.get(2)?;
195 let configured_apps: Vec<String> =
196 serde_json::from_str(&apps_json).map_err(|e| {
197 rusqlite::Error::FromSqlConversionFailure(
198 2,
199 rusqlite::types::Type::Text,
200 Box::new(e),
201 )
202 })?;
203
204 let last_updated_str: String = row.get(3)?;
205 let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
206 .map_err(|e| {
207 rusqlite::Error::FromSqlConversionFailure(
208 3,
209 rusqlite::types::Type::Text,
210 Box::new(e),
211 )
212 })?
213 .with_timezone(&Utc);
214
215 Ok(CachedRepository {
216 name: row.get(0)?,
217 path: PathBuf::from(row.get::<_, String>(1)?),
218 configured_apps,
219 last_updated,
220 path_exists: row.get::<_, i32>(4)? != 0,
221 is_git_repo: row.get::<_, i32>(5)? != 0,
222 })
223 })?;
224
225 let mut repositories = Vec::new();
226 for repo in repo_iter {
227 repositories.push(repo?);
228 }
229
230 Ok(repositories)
231 })
232 .await
233 .context("Failed to get repositories with apps")?;
234
235 Ok(repositories)
236 }
237
238 pub async fn refresh_from_config(
240 &self,
241 repositories: &[crate::workspace::Repository],
242 workspace_root: &std::path::Path,
243 ) -> Result<()> {
244 for repo in repositories {
245 let full_path = workspace_root.join(&repo.path);
246 let cached_repo = CachedRepository {
247 name: repo.name.clone(),
248 path: repo.path.clone(),
249 configured_apps: repo.apps.keys().cloned().collect(),
250 last_updated: Utc::now(),
251 path_exists: full_path.exists(),
252 is_git_repo: full_path.join(".git").exists(),
253 };
254
255 self.cache_repository(&cached_repo).await?;
256 }
257
258 Ok(())
259 }
260
261 pub async fn cleanup_stale_entries(&self, current_repo_names: &[String]) -> Result<()> {
263 let conn = Connection::open(&self.db_path).await?;
264 let current_names = current_repo_names.to_vec();
265
266 conn.call(move |conn| {
267 conn.execute("CREATE TEMP TABLE current_repos (name TEXT)", [])?;
269
270 let mut stmt = conn.prepare("INSERT INTO current_repos (name) VALUES (?1)")?;
271 for name in current_names {
272 stmt.execute(params![name])?;
273 }
274
275 conn.execute(
277 "DELETE FROM repositories WHERE name NOT IN (SELECT name FROM current_repos)",
278 [],
279 )?;
280
281 Ok(())
282 })
283 .await
284 .context("Failed to cleanup stale cache entries")?;
285
286 Ok(())
287 }
288
289 pub async fn get_stats(&self) -> Result<CacheStats> {
291 let conn = Connection::open(&self.db_path).await?;
292
293 let stats = conn
294 .call(move |conn| {
295 let total_repos: i64 = conn.query_row(
296 "SELECT COUNT(*) FROM repositories",
297 [],
298 |row| row.get(0)
299 )?;
300
301 let repos_with_apps: i64 = conn.query_row(
302 "SELECT COUNT(*) FROM repositories WHERE json_array_length(configured_apps) > 0",
303 [],
304 |row| row.get(0)
305 )?;
306
307 let existing_repos: i64 = conn.query_row(
308 "SELECT COUNT(*) FROM repositories WHERE path_exists = 1",
309 [],
310 |row| row.get(0)
311 )?;
312
313 Ok(CacheStats {
314 total_repositories: total_repos as usize,
315 repositories_with_apps: repos_with_apps as usize,
316 existing_repositories: existing_repos as usize,
317 })
318 })
319 .await
320 .context("Failed to get cache statistics")?;
321
322 Ok(stats)
323 }
324}
325
326#[derive(Debug, Clone)]
328pub struct CacheStats {
329 pub total_repositories: usize,
330 pub repositories_with_apps: usize,
331 pub existing_repositories: usize,
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use tempfile::tempdir;
338
339 #[tokio::test]
340 async fn test_repository_cache_operations() {
341 let temp_dir = tempdir().unwrap();
342 let db_path = temp_dir.path().join("test_repos.db");
343
344 let cache = RepositoryCache::new(db_path);
345 cache.initialize().await.unwrap();
346
347 let repo = CachedRepository {
349 name: "test-repo".to_string(),
350 path: PathBuf::from("/path/to/repo"),
351 configured_apps: vec!["vscode".to_string(), "warp".to_string()],
352 last_updated: Utc::now(),
353 path_exists: true,
354 is_git_repo: true,
355 };
356
357 cache.cache_repository(&repo).await.unwrap();
358
359 let cached = cache.get_repository("test-repo").await.unwrap();
361 assert!(cached.is_some());
362
363 let cached = cached.unwrap();
364 assert_eq!(cached.name, "test-repo");
365 assert_eq!(cached.configured_apps, vec!["vscode", "warp"]);
366
367 let repos_with_apps = cache.get_repositories_with_apps().await.unwrap();
369 assert_eq!(repos_with_apps.len(), 1);
370 assert_eq!(repos_with_apps[0].name, "test-repo");
371 }
372}