Skip to main content

vibe_workspace/cache/
repository_cache.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tokio_rusqlite::{params, Connection};
6
7/// Cached repository information for fast lookups
8#[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
18/// Fast SQLite-based cache for repository metadata
19pub struct RepositoryCache {
20    db_path: PathBuf,
21}
22
23impl RepositoryCache {
24    /// Create a new repository cache
25    pub fn new<P: Into<PathBuf>>(db_path: P) -> Self {
26        Self {
27            db_path: db_path.into(),
28        }
29    }
30
31    /// Initialize the cache database with required tables
32    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            // Check if we need to migrate from old schema
39            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                // Drop old table and recreate with new schema
51                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            // Create index for faster lookups
69            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    /// Cache repository information
88    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    /// Get cached repository by name
122    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    /// Get all repositories with configured apps (for launch UI)
178    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    /// Update cache for all repositories in workspace config
239    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    /// Remove repositories that no longer exist in config
262    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            // Create a temporary table with current repo names
268            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            // Delete repositories not in current config
276            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    /// Get cache statistics
290    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/// Cache statistics for monitoring and debugging
327#[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        // Test caching a repository
348        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        // Test retrieving the repository
360        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        // Test getting repositories with apps
368        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}