Skip to main content

git_same/cache/
discovery.rs

1use 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
10/// Default cache TTL (1 hour)
11const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(3600);
12
13/// Current cache format version.
14/// Increment this when making breaking changes to the cache format.
15pub const CACHE_VERSION: u32 = 1;
16
17/// Discovery cache data.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DiscoveryCache {
20    /// Cache format version for forward compatibility.
21    /// If missing during deserialization, defaults to 0 (pre-versioned cache).
22    #[serde(default)]
23    pub version: u32,
24
25    /// When the discovery was last performed (Unix timestamp).
26    pub last_discovery: u64,
27
28    /// Username or identifier.
29    pub username: String,
30
31    /// List of organization names.
32    pub orgs: Vec<String>,
33
34    /// Total number of repositories discovered.
35    pub repo_count: usize,
36
37    /// Cached repositories by provider.
38    pub repos: HashMap<String, Vec<OwnedRepo>>,
39}
40
41impl DiscoveryCache {
42    /// Create a new cache entry.
43    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    /// Check if this cache is compatible with the current version.
76    pub fn is_compatible(&self) -> bool {
77        self.version == CACHE_VERSION
78    }
79
80    /// Check if the cache is still valid.
81    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    /// Get the age of the cache in seconds.
96    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
106/// Discovery cache manager.
107pub struct CacheManager {
108    cache_path: PathBuf,
109    ttl: Duration,
110}
111
112impl CacheManager {
113    /// Create a cache manager for a specific workspace root path.
114    ///
115    /// Cache is persisted at `<workspace-root>/.git-same/cache.json`.
116    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    /// Create a cache manager with a custom path.
125    pub fn with_path(cache_path: PathBuf) -> Self {
126        Self {
127            cache_path,
128            ttl: DEFAULT_CACHE_TTL,
129        }
130    }
131
132    /// Create a cache manager with a custom TTL.
133    pub fn with_ttl(mut self, ttl: Duration) -> Self {
134        self.ttl = ttl;
135        self
136    }
137
138    /// Load the cache if it exists and is valid.
139    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    /// Save the cache to disk.
191    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    /// Clear the cache file.
211    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    /// Get the cache path.
219    pub fn path(&self) -> &Path {
220        &self.cache_path
221    }
222}
223
224#[cfg(test)]
225#[path = "discovery_tests.rs"]
226mod tests;