Skip to main content

meritocrab_api/
repo_config_loader.rs

1use crate::error::{ApiError, ApiResult};
2use meritocrab_core::RepoConfig;
3use meritocrab_github::GithubApiClient;
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::sync::RwLock;
8use tracing::{info, warn};
9
10/// Cached repository configuration with TTL
11#[derive(Debug, Clone)]
12struct CachedConfig {
13    config: RepoConfig,
14    fetched_at: Instant,
15}
16
17/// Repository configuration loader with caching
18///
19/// Fetches `.meritocrab.toml` from repository root via GitHub API
20/// and caches it with configurable TTL. Falls back to default config
21/// if file is missing or invalid.
22pub struct RepoConfigLoader {
23    github_client: Arc<GithubApiClient>,
24    cache: Arc<RwLock<HashMap<String, CachedConfig>>>,
25    cache_ttl: Duration,
26    default_config: RepoConfig,
27}
28
29impl RepoConfigLoader {
30    /// Create new config loader
31    ///
32    /// # Arguments
33    /// * `github_client` - GitHub API client for fetching config files
34    /// * `cache_ttl_seconds` - TTL for cached configs in seconds
35    pub fn new(github_client: Arc<GithubApiClient>, cache_ttl_seconds: u64) -> Self {
36        Self {
37            github_client,
38            cache: Arc::new(RwLock::new(HashMap::new())),
39            cache_ttl: Duration::from_secs(cache_ttl_seconds),
40            default_config: RepoConfig::default(),
41        }
42    }
43
44    /// Get configuration for a repository
45    ///
46    /// Checks cache first, then fetches from GitHub if cache miss or expired.
47    /// Returns default config if file is missing or invalid.
48    pub async fn get_config(&self, repo_owner: &str, repo_name: &str) -> RepoConfig {
49        let cache_key = format!("{}/{}", repo_owner, repo_name);
50
51        // Check cache
52        {
53            let cache_guard = self.cache.read().await;
54            if let Some(cached) = cache_guard.get(&cache_key) {
55                if cached.fetched_at.elapsed() < self.cache_ttl {
56                    info!("Using cached config for {}/{}", repo_owner, repo_name);
57                    return cached.config.clone();
58                }
59            }
60        }
61
62        // Cache miss or expired, fetch from GitHub
63        info!("Fetching .meritocrab.toml for {}/{}", repo_owner, repo_name);
64
65        match self.fetch_config_from_github(repo_owner, repo_name).await {
66            Ok(config) => {
67                // Update cache
68                let mut cache_guard = self.cache.write().await;
69                cache_guard.insert(
70                    cache_key.clone(),
71                    CachedConfig {
72                        config: config.clone(),
73                        fetched_at: Instant::now(),
74                    },
75                );
76                info!("Cached config for {}/{}", repo_owner, repo_name);
77                config
78            }
79            Err(e) => {
80                warn!(
81                    "Failed to fetch config for {}/{}: {}. Using defaults.",
82                    repo_owner, repo_name, e
83                );
84                self.default_config.clone()
85            }
86        }
87    }
88
89    /// Fetch .meritocrab.toml from GitHub repository
90    async fn fetch_config_from_github(
91        &self,
92        repo_owner: &str,
93        repo_name: &str,
94    ) -> ApiResult<RepoConfig> {
95        // Fetch file content from GitHub
96        let file_content = self
97            .github_client
98            .get_file_content(repo_owner, repo_name, ".meritocrab.toml")
99            .await?;
100
101        // Parse TOML
102        let config: RepoConfig = toml::from_str(&file_content).map_err(|e| {
103            warn!(
104                "Invalid .meritocrab.toml syntax for {}/{}: {}",
105                repo_owner, repo_name, e
106            );
107            ApiError::Internal(format!("Invalid TOML syntax: {}", e))
108        })?;
109
110        info!(
111            "Successfully loaded config for {}/{}: starting_credit={}, pr_threshold={}, blacklist_threshold={}",
112            repo_owner,
113            repo_name,
114            config.starting_credit,
115            config.pr_threshold,
116            config.blacklist_threshold
117        );
118
119        Ok(config)
120    }
121
122    /// Clear cache for a specific repository
123    #[allow(dead_code)]
124    pub async fn invalidate_cache(&self, repo_owner: &str, repo_name: &str) {
125        let cache_key = format!("{}/{}", repo_owner, repo_name);
126        let mut cache_guard = self.cache.write().await;
127        cache_guard.remove(&cache_key);
128        info!("Invalidated cache for {}/{}", repo_owner, repo_name);
129    }
130
131    /// Clear all cached configs
132    #[allow(dead_code)]
133    pub async fn clear_cache(&self) {
134        let mut cache_guard = self.cache.write().await;
135        cache_guard.clear();
136        info!("Cleared all config cache");
137    }
138
139    /// Get cache statistics (for monitoring)
140    #[allow(dead_code)]
141    pub async fn cache_size(&self) -> usize {
142        let cache_guard = self.cache.read().await;
143        cache_guard.len()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use meritocrab_github::GithubApiClient;
151
152    #[tokio::test]
153    async fn test_loader_returns_default_on_error() {
154        // Initialize rustls crypto provider for tests
155        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
156
157        // Create GitHub client (will fail when called)
158        let github_client = Arc::new(
159            GithubApiClient::new("test-token".to_string()).expect("Failed to create client"),
160        );
161
162        let loader = RepoConfigLoader::new(github_client, 300);
163        let config = loader.get_config("owner", "repo").await;
164
165        // Should return defaults since GitHub fetch will fail
166        assert_eq!(config.starting_credit, 100);
167        assert_eq!(config.pr_threshold, 50);
168        assert_eq!(config.blacklist_threshold, 0);
169    }
170
171    #[tokio::test]
172    async fn test_cache_ttl() {
173        // Initialize rustls crypto provider for tests
174        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
175
176        let github_client = Arc::new(
177            GithubApiClient::new("test-token".to_string()).expect("Failed to create client"),
178        );
179
180        // Very short TTL for testing
181        let loader = RepoConfigLoader::new(github_client, 1);
182
183        // First fetch (cache miss, will fail and return defaults, NOT cached)
184        let config1 = loader.get_config("owner", "repo").await;
185        // Cache is empty because fetch failed
186        assert_eq!(loader.cache_size().await, 0);
187
188        // Second fetch (cache miss again)
189        let config2 = loader.get_config("owner", "repo").await;
190        assert_eq!(config1.starting_credit, config2.starting_credit);
191
192        // Cache is still empty because fetches fail in tests
193        assert_eq!(loader.cache_size().await, 0);
194    }
195
196    #[tokio::test]
197    async fn test_invalidate_cache() {
198        // Initialize rustls crypto provider for tests
199        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
200
201        let github_client = Arc::new(
202            GithubApiClient::new("test-token".to_string()).expect("Failed to create client"),
203        );
204
205        let loader = RepoConfigLoader::new(github_client, 300);
206
207        // Fetch config (will fail and not be cached)
208        let _config = loader.get_config("owner", "repo").await;
209        assert_eq!(loader.cache_size().await, 0);
210
211        // Invalidate (cache is already empty, this is a no-op)
212        loader.invalidate_cache("owner", "repo").await;
213        assert_eq!(loader.cache_size().await, 0);
214    }
215
216    #[tokio::test]
217    async fn test_clear_cache() {
218        // Initialize rustls crypto provider for tests
219        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
220
221        let github_client = Arc::new(
222            GithubApiClient::new("test-token".to_string()).expect("Failed to create client"),
223        );
224
225        let loader = RepoConfigLoader::new(github_client, 300);
226
227        // Fetch multiple configs (will fail and not be cached)
228        let _config1 = loader.get_config("owner1", "repo1").await;
229        let _config2 = loader.get_config("owner2", "repo2").await;
230        assert_eq!(loader.cache_size().await, 0);
231
232        // Clear all (cache is already empty, this is a no-op)
233        loader.clear_cache().await;
234        assert_eq!(loader.cache_size().await, 0);
235    }
236}