Skip to main content

vibe_workspace/worktree/
cache.rs

1//! Caching layer for worktree status to improve performance
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime};
8
9use crate::worktree::status::WorktreeInfo;
10
11const CACHE_TTL_SECONDS: u64 = 300; // 5 minutes
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WorktreeStatusCache {
15    entries: HashMap<PathBuf, CacheEntry>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19struct CacheEntry {
20    worktree_info: WorktreeInfo,
21    last_updated: SystemTime,
22    file_mtime: SystemTime,
23}
24
25impl WorktreeStatusCache {
26    pub fn new() -> Self {
27        Self {
28            entries: HashMap::new(),
29        }
30    }
31
32    /// Get cached worktree info if still valid
33    pub fn get(&self, path: &Path) -> Option<&WorktreeInfo> {
34        if let Some(entry) = self.entries.get(path) {
35            // Check if cache is still valid
36            if self.is_entry_valid(entry, path).unwrap_or(false) {
37                return Some(&entry.worktree_info);
38            }
39        }
40
41        None
42    }
43
44    /// Store worktree info in cache
45    pub fn insert(&mut self, path: PathBuf, info: WorktreeInfo) -> Result<()> {
46        let file_mtime = std::fs::metadata(&path)
47            .and_then(|m| m.modified())
48            .unwrap_or_else(|_| SystemTime::now());
49
50        let entry = CacheEntry {
51            worktree_info: info,
52            last_updated: SystemTime::now(),
53            file_mtime,
54        };
55
56        self.entries.insert(path, entry);
57        Ok(())
58    }
59
60    /// Remove stale entries from cache
61    pub fn cleanup_stale_entries(&mut self) {
62        let now = SystemTime::now();
63        let ttl = Duration::from_secs(CACHE_TTL_SECONDS);
64
65        self.entries.retain(|path, entry| {
66            // Remove if too old or if path no longer exists
67            if let Ok(age) = now.duration_since(entry.last_updated) {
68                age < ttl && path.exists()
69            } else {
70                false
71            }
72        });
73    }
74
75    /// Check if a cache entry is still valid
76    fn is_entry_valid(&self, entry: &CacheEntry, path: &Path) -> Result<bool> {
77        let now = SystemTime::now();
78        let ttl = Duration::from_secs(CACHE_TTL_SECONDS);
79
80        // Check age
81        if now.duration_since(entry.last_updated)? > ttl {
82            return Ok(false);
83        }
84
85        // Check if directory was modified
86        if let Ok(metadata) = std::fs::metadata(path) {
87            if let Ok(current_mtime) = metadata.modified() {
88                if current_mtime > entry.file_mtime {
89                    return Ok(false);
90                }
91            }
92        }
93
94        Ok(true)
95    }
96
97    /// Get cache statistics
98    pub fn stats(&self) -> CacheStats {
99        let total_entries = self.entries.len();
100        let now = SystemTime::now();
101
102        let valid_entries = self
103            .entries
104            .values()
105            .filter(|entry| {
106                now.duration_since(entry.last_updated)
107                    .map(|age| age.as_secs() < CACHE_TTL_SECONDS)
108                    .unwrap_or(false)
109            })
110            .count();
111
112        CacheStats {
113            total_entries,
114            valid_entries,
115            hit_ratio: if total_entries > 0 {
116                valid_entries as f64 / total_entries as f64
117            } else {
118                0.0
119            },
120        }
121    }
122}
123
124#[derive(Debug)]
125pub struct CacheStats {
126    pub total_entries: usize,
127    pub valid_entries: usize,
128    pub hit_ratio: f64,
129}
130
131impl Default for WorktreeStatusCache {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::worktree::status::{StatusSeverity, WorktreeStatus};
141    use std::time::Duration;
142    use tempfile::TempDir;
143
144    fn create_test_worktree_info(path: PathBuf) -> WorktreeInfo {
145        WorktreeInfo {
146            path,
147            branch: "test-branch".to_string(),
148            head: "abc1234".to_string(),
149            task_id: None,
150            status: WorktreeStatus {
151                is_clean: true,
152                severity: StatusSeverity::Clean,
153                uncommitted_changes: Vec::new(),
154                untracked_files: Vec::new(),
155                unpushed_commits: Vec::new(),
156                remote_status: crate::worktree::status::RemoteStatus::UpToDate,
157                merge_info: None,
158                ahead_count: 0,
159                behind_count: 0,
160            },
161            age: Duration::from_secs(3600),
162            is_detached: false,
163        }
164    }
165
166    #[test]
167    fn test_cache_basic_operations() {
168        let mut cache = WorktreeStatusCache::new();
169        let temp_dir = TempDir::new().unwrap();
170        let path = temp_dir.path().to_path_buf();
171
172        let worktree_info = create_test_worktree_info(path.clone());
173
174        // Test cache miss
175        assert!(cache.get(&path).is_none());
176
177        // Test cache hit after insert
178        cache.insert(path.clone(), worktree_info).unwrap();
179        assert!(cache.get(&path).is_some());
180
181        let cached_info = cache.get(&path).unwrap();
182        assert_eq!(cached_info.branch, "test-branch");
183        assert_eq!(cached_info.head, "abc1234");
184    }
185
186    #[test]
187    fn test_cache_cleanup() {
188        let mut cache = WorktreeStatusCache::new();
189        let temp_dir = TempDir::new().unwrap();
190        let path = temp_dir.path().to_path_buf();
191
192        let worktree_info = create_test_worktree_info(path.clone());
193        cache.insert(path.clone(), worktree_info).unwrap();
194
195        assert_eq!(cache.entries.len(), 1);
196
197        // Cleanup should remove the entry since the temp dir might not exist after drop
198        cache.cleanup_stale_entries();
199        // Note: This test might be flaky depending on filesystem behavior
200    }
201
202    #[test]
203    fn test_cache_stats() {
204        let mut cache = WorktreeStatusCache::new();
205        let temp_dir = TempDir::new().unwrap();
206        let path = temp_dir.path().to_path_buf();
207
208        // Empty cache stats
209        let stats = cache.stats();
210        assert_eq!(stats.total_entries, 0);
211        assert_eq!(stats.valid_entries, 0);
212        assert_eq!(stats.hit_ratio, 0.0);
213
214        // Add entry
215        let worktree_info = create_test_worktree_info(path);
216        cache
217            .insert(temp_dir.path().to_path_buf(), worktree_info)
218            .unwrap();
219
220        let stats = cache.stats();
221        assert_eq!(stats.total_entries, 1);
222        assert_eq!(stats.valid_entries, 1);
223        assert_eq!(stats.hit_ratio, 1.0);
224    }
225}