git_same/cache/
discovery.rs1use crate::types::OwnedRepo;
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8use tracing::{debug, warn};
9
10const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(3600);
12
13pub const CACHE_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DiscoveryCache {
20 #[serde(default)]
23 pub version: u32,
24
25 pub last_discovery: u64,
27
28 pub username: String,
30
31 pub orgs: Vec<String>,
33
34 pub repo_count: usize,
36
37 pub repos: HashMap<String, Vec<OwnedRepo>>,
39}
40
41impl DiscoveryCache {
42 pub fn new(username: String, repos: HashMap<String, Vec<OwnedRepo>>) -> Self {
44 let orgs: Vec<String> = repos
45 .values()
46 .flat_map(|r| r.iter().map(|owned| owned.owner.clone()))
47 .collect::<std::collections::HashSet<_>>()
48 .into_iter()
49 .collect();
50
51 let repo_count = repos.values().map(|r| r.len()).sum();
52
53 let now = SystemTime::now()
54 .duration_since(UNIX_EPOCH)
55 .unwrap_or_default()
56 .as_secs();
57
58 debug!(
59 version = CACHE_VERSION,
60 repo_count,
61 org_count = orgs.len(),
62 "Creating new discovery cache"
63 );
64
65 Self {
66 version: CACHE_VERSION,
67 last_discovery: now,
68 username,
69 orgs,
70 repo_count,
71 repos,
72 }
73 }
74
75 pub fn is_compatible(&self) -> bool {
77 self.version == CACHE_VERSION
78 }
79
80 pub fn is_valid(&self, ttl: Duration) -> bool {
82 let now = SystemTime::now()
83 .duration_since(UNIX_EPOCH)
84 .unwrap_or_default()
85 .as_secs();
86
87 if now < self.last_discovery {
88 return false;
89 }
90
91 let age = now - self.last_discovery;
92 age < ttl.as_secs()
93 }
94
95 pub fn age_secs(&self) -> u64 {
97 let now = SystemTime::now()
98 .duration_since(UNIX_EPOCH)
99 .unwrap_or_default()
100 .as_secs();
101
102 now.saturating_sub(self.last_discovery)
103 }
104}
105
106pub struct CacheManager {
108 cache_path: PathBuf,
109 ttl: Duration,
110}
111
112impl CacheManager {
113 pub fn for_workspace(root: &Path) -> Result<Self> {
117 let cache_path = crate::config::WorkspaceStore::cache_path(root);
118 Ok(Self {
119 cache_path,
120 ttl: DEFAULT_CACHE_TTL,
121 })
122 }
123
124 pub fn with_path(cache_path: PathBuf) -> Self {
126 Self {
127 cache_path,
128 ttl: DEFAULT_CACHE_TTL,
129 }
130 }
131
132 pub fn with_ttl(mut self, ttl: Duration) -> Self {
134 self.ttl = ttl;
135 self
136 }
137
138 pub fn load(&self) -> Result<Option<DiscoveryCache>> {
140 if !self.cache_path.exists() {
141 debug!(path = %self.cache_path.display(), "Cache file does not exist");
142 return Ok(None);
143 }
144
145 let content = match fs::read_to_string(&self.cache_path) {
146 Ok(content) => content,
147 Err(err) => {
148 warn!(
149 path = %self.cache_path.display(),
150 error = %err,
151 "Cache file unreadable, ignoring cache"
152 );
153 return Ok(None);
154 }
155 };
156 let cache: DiscoveryCache = match serde_json::from_str(&content) {
157 Ok(cache) => cache,
158 Err(err) => {
159 warn!(
160 path = %self.cache_path.display(),
161 error = %err,
162 "Cache file malformed, ignoring cache"
163 );
164 return Ok(None);
165 }
166 };
167
168 if !cache.is_compatible() {
169 warn!(
170 cache_version = cache.version,
171 current_version = CACHE_VERSION,
172 "Cache version mismatch, ignoring stale cache"
173 );
174 return Ok(None);
175 }
176
177 if cache.is_valid(self.ttl) {
178 debug!(
179 age_secs = cache.age_secs(),
180 repo_count = cache.repo_count,
181 "Loaded valid cache"
182 );
183 Ok(Some(cache))
184 } else {
185 debug!(age_secs = cache.age_secs(), "Cache expired");
186 Ok(None)
187 }
188 }
189
190 pub fn save(&self, cache: &DiscoveryCache) -> Result<()> {
192 if let Some(parent) = self.cache_path.parent() {
193 fs::create_dir_all(parent).context("Failed to create cache directory")?;
194 }
195
196 let json = serde_json::to_string_pretty(cache).context("Failed to serialize cache")?;
197 fs::write(&self.cache_path, &json).context("Failed to write cache file")?;
198
199 debug!(
200 path = %self.cache_path.display(),
201 version = cache.version,
202 repo_count = cache.repo_count,
203 bytes = json.len(),
204 "Saved cache to disk"
205 );
206
207 Ok(())
208 }
209
210 pub fn clear(&self) -> Result<()> {
212 if self.cache_path.exists() {
213 fs::remove_file(&self.cache_path).context("Failed to remove cache file")?;
214 }
215 Ok(())
216 }
217
218 pub fn path(&self) -> &Path {
220 &self.cache_path
221 }
222}
223
224#[cfg(test)]
225#[path = "discovery_tests.rs"]
226mod tests;