1use once_cell::sync::Lazy;
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use super::status::RepoStatusInfo;
12
13#[cfg(feature = "telemetry")]
14use crate::telemetry::metrics::GLOBAL_METRICS;
15#[cfg(feature = "telemetry")]
16use tracing::trace;
17
18struct CacheEntry {
20 status: RepoStatusInfo,
21 timestamp: Instant,
22}
23
24pub struct GitStatusCache {
26 cache: Mutex<HashMap<PathBuf, CacheEntry>>,
27 ttl: Duration,
28}
29
30impl GitStatusCache {
31 pub fn new(ttl: Duration) -> Self {
33 Self {
34 cache: Mutex::new(HashMap::new()),
35 ttl,
36 }
37 }
38
39 fn is_expired(&self, entry: &CacheEntry) -> bool {
41 entry.timestamp.elapsed() > self.ttl
42 }
43
44 pub fn get(&self, repo_path: &PathBuf) -> Option<RepoStatusInfo> {
46 let cache = self.cache.lock().expect("mutex poisoned");
47 if let Some(entry) = cache.get(repo_path) {
48 if !self.is_expired(entry) {
49 #[cfg(feature = "telemetry")]
50 {
51 GLOBAL_METRICS.record_cache(true);
52 trace!(path = %repo_path.display(), "Cache hit");
53 }
54 return Some(entry.status.clone());
55 }
56 }
57 #[cfg(feature = "telemetry")]
58 {
59 GLOBAL_METRICS.record_cache(false);
60 trace!(path = %repo_path.display(), "Cache miss");
61 }
62 None
63 }
64
65 pub fn set(&self, repo_path: PathBuf, status: RepoStatusInfo) {
67 let mut cache = self.cache.lock().expect("mutex poisoned");
68 cache.insert(
69 repo_path,
70 CacheEntry {
71 status,
72 timestamp: Instant::now(),
73 },
74 );
75 }
76
77 pub fn invalidate(&self, repo_path: &PathBuf) {
79 let mut cache = self.cache.lock().expect("mutex poisoned");
80 cache.remove(repo_path);
81 }
82
83 pub fn clear(&self) {
85 let mut cache = self.cache.lock().expect("mutex poisoned");
86 cache.clear();
87 }
88}
89
90impl Default for GitStatusCache {
91 fn default() -> Self {
92 Self::new(Duration::from_millis(5000))
93 }
94}
95
96pub static STATUS_CACHE: Lazy<GitStatusCache> = Lazy::new(GitStatusCache::default);
98
99pub fn invalidate_status_cache(repo_path: &PathBuf) {
101 STATUS_CACHE.invalidate(repo_path);
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn test_cache_set_get() {
110 let cache = GitStatusCache::new(Duration::from_secs(60));
111 let path = PathBuf::from("/test/repo");
112 let status = RepoStatusInfo {
113 current_branch: "main".to_string(),
114 is_clean: true,
115 staged: vec![],
116 modified: vec![],
117 untracked: vec![],
118 ahead: 0,
119 behind: 0,
120 };
121
122 cache.set(path.clone(), status.clone());
123 let cached = cache.get(&path).unwrap();
124 assert_eq!(cached.current_branch, "main");
125 assert!(cached.is_clean);
126 }
127
128 #[test]
129 fn test_cache_invalidate() {
130 let cache = GitStatusCache::new(Duration::from_secs(60));
131 let path = PathBuf::from("/test/repo");
132 let status = RepoStatusInfo {
133 current_branch: "main".to_string(),
134 is_clean: true,
135 staged: vec![],
136 modified: vec![],
137 untracked: vec![],
138 ahead: 0,
139 behind: 0,
140 };
141
142 cache.set(path.clone(), status);
143 assert!(cache.get(&path).is_some());
144
145 cache.invalidate(&path);
146 assert!(cache.get(&path).is_none());
147 }
148
149 #[test]
150 fn test_cache_expiry() {
151 let cache = GitStatusCache::new(Duration::from_millis(10));
152 let path = PathBuf::from("/test/repo");
153 let status = RepoStatusInfo {
154 current_branch: "main".to_string(),
155 is_clean: true,
156 staged: vec![],
157 modified: vec![],
158 untracked: vec![],
159 ahead: 0,
160 behind: 0,
161 };
162
163 cache.set(path.clone(), status);
164 assert!(cache.get(&path).is_some());
165
166 std::thread::sleep(Duration::from_millis(20));
167 assert!(cache.get(&path).is_none());
168 }
169}