Skip to main content

pr_bro/github/
cache.rs

1use anyhow::{Context, Result};
2use http::{HeaderMap, Uri};
3use octocrab::service::middleware::cache::{CacheKey, CacheStorage, CacheWriter, CachedResponse};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7
8/// Configuration for HTTP response caching
9#[derive(Clone, Debug)]
10pub struct CacheConfig {
11    pub enabled: bool, // false when --no-cache
12}
13
14/// Get the platform-appropriate cache directory for pr-bro
15pub fn get_cache_path() -> PathBuf {
16    dirs::cache_dir()
17        .map(|p| p.join("pr-bro/http-cache"))
18        .unwrap_or_else(|| {
19            PathBuf::from(format!(
20                "{}/.cache/pr-bro/http-cache",
21                std::env::var("HOME").unwrap_or_default()
22            ))
23        })
24}
25
26/// Clear the HTTP cache directory
27pub fn clear_cache() -> Result<()> {
28    let cache_path = get_cache_path();
29    match std::fs::remove_dir_all(&cache_path) {
30        Ok(()) => Ok(()),
31        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
32        Err(e) => Err(e).context("Failed to remove cache directory"),
33    }
34}
35
36/// Disk-persistent cache implementing octocrab's CacheStorage trait
37///
38/// Uses cacache for disk persistence and in-memory HashMap for fast access.
39/// Responses are cached by URI with ETag/Last-Modified headers for conditional requests.
40#[derive(Clone)]
41pub struct DiskCache {
42    inner: Arc<Mutex<CacheData>>,
43    cache_path: PathBuf,
44}
45
46struct CacheData {
47    keys: HashMap<String, CacheKey>,            // URI string -> CacheKey
48    responses: HashMap<String, CachedResponse>, // URI string -> cached response
49}
50
51/// Serializable representation of a cache entry for disk storage
52#[derive(serde::Serialize, serde::Deserialize)]
53struct DiskCacheEntry {
54    etag: Option<String>,
55    last_modified: Option<String>,
56    headers: Vec<(String, Vec<u8>)>, // header name -> value bytes
57    body: Vec<u8>,
58}
59
60impl DiskCacheEntry {
61    /// Create a DiskCacheEntry from CacheKey and CachedResponse
62    fn from_parts(key: &CacheKey, response: &CachedResponse) -> Self {
63        let (etag, last_modified) = match key {
64            CacheKey::ETag(etag) => (Some(etag.clone()), None),
65            CacheKey::LastModified(lm) => (None, Some(lm.clone())),
66            _ => (None, None), // Handle non-exhaustive enum
67        };
68
69        let headers: Vec<(String, Vec<u8>)> = response
70            .headers
71            .iter()
72            .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec()))
73            .collect();
74
75        Self {
76            etag,
77            last_modified,
78            headers,
79            body: response.body.clone(),
80        }
81    }
82
83    /// Convert back to CacheKey and CachedResponse
84    fn to_parts(&self) -> Result<(CacheKey, CachedResponse)> {
85        let key = if let Some(etag) = &self.etag {
86            CacheKey::ETag(etag.clone())
87        } else if let Some(lm) = &self.last_modified {
88            CacheKey::LastModified(lm.clone())
89        } else {
90            anyhow::bail!("Invalid cache entry: no ETag or Last-Modified");
91        };
92
93        let mut headers = HeaderMap::new();
94        for (name, value) in &self.headers {
95            let header_name: http::HeaderName = name.parse().context("Invalid header name")?;
96            let header_value =
97                http::HeaderValue::from_bytes(value).context("Invalid header value")?;
98            headers.insert(header_name, header_value);
99        }
100
101        let response = CachedResponse {
102            headers,
103            body: self.body.clone(),
104        };
105
106        Ok((key, response))
107    }
108}
109
110impl DiskCache {
111    pub fn new(cache_path: PathBuf) -> Self {
112        // Don't pre-load disk cache - entries are loaded on demand
113        Self {
114            inner: Arc::new(Mutex::new(CacheData {
115                keys: HashMap::new(),
116                responses: HashMap::new(),
117            })),
118            cache_path,
119        }
120    }
121
122    /// Clear the in-memory cache to force fresh requests on next fetch
123    pub fn clear_memory(&self) {
124        let mut data = self.inner.lock().unwrap();
125        data.keys.clear();
126        data.responses.clear();
127    }
128
129    /// Try to load a cache entry from disk
130    fn load_from_disk(&self, uri_key: &str) -> Option<CacheKey> {
131        // Try to read from disk
132        let bytes = cacache::read_sync(&self.cache_path, uri_key).ok()?;
133
134        // Deserialize
135        let entry: DiskCacheEntry = serde_json::from_slice(&bytes).ok()?;
136
137        // Convert to CacheKey and CachedResponse
138        let (key, response) = entry.to_parts().ok()?;
139
140        // Populate in-memory cache for subsequent hits
141        let mut data = self.inner.lock().unwrap();
142        data.keys.insert(uri_key.to_string(), key.clone());
143        data.responses.insert(uri_key.to_string(), response);
144
145        Some(key)
146    }
147}
148
149impl CacheStorage for DiskCache {
150    fn try_hit(&self, uri: &Uri) -> Option<CacheKey> {
151        let uri_key = uri.to_string();
152
153        // Check in-memory first
154        {
155            let data = self.inner.lock().unwrap();
156            if let Some(cache_key) = data.keys.get(&uri_key) {
157                return Some(cache_key.clone());
158            }
159        }
160
161        // Try loading from disk
162        self.load_from_disk(&uri_key)
163    }
164
165    fn load(&self, uri: &Uri) -> Option<CachedResponse> {
166        let data = self.inner.lock().unwrap();
167        data.responses.get(&uri.to_string()).cloned()
168    }
169
170    fn writer(&self, uri: &Uri, key: CacheKey, headers: HeaderMap) -> Box<dyn CacheWriter> {
171        Box::new(DiskCacheWriter {
172            cache: self.inner.clone(),
173            cache_path: self.cache_path.clone(),
174            uri_key: uri.to_string(),
175            key,
176            response: CachedResponse {
177                body: Vec::new(),
178                headers,
179            },
180        })
181    }
182}
183
184/// Writer that persists cache entries to both memory and disk
185struct DiskCacheWriter {
186    cache: Arc<Mutex<CacheData>>,
187    cache_path: PathBuf,
188    uri_key: String,
189    key: CacheKey,
190    response: CachedResponse,
191}
192
193impl CacheWriter for DiskCacheWriter {
194    fn write_body(&mut self, data: &[u8]) {
195        self.response.body.extend_from_slice(data);
196    }
197}
198
199impl Drop for DiskCacheWriter {
200    fn drop(&mut self) {
201        let uri_key = self.uri_key.clone();
202        let key = self.key.clone();
203        let response = CachedResponse {
204            body: std::mem::take(&mut self.response.body),
205            headers: self.response.headers.clone(),
206        };
207
208        // Write to in-memory cache
209        {
210            let mut data = self.cache.lock().unwrap();
211            data.keys.insert(uri_key.clone(), key.clone());
212            data.responses.insert(uri_key.clone(), response.clone());
213        }
214
215        // Write to disk (fire-and-forget, don't block on disk errors)
216        let entry = DiskCacheEntry::from_parts(&key, &response);
217        if let Ok(serialized) = serde_json::to_vec(&entry) {
218            let _ = cacache::write_sync(&self.cache_path, &uri_key, &serialized);
219        }
220    }
221}