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(&self, repo_owner: &str, repo_name: &str) -> ApiResult<RepoConfig> {
91        // Fetch file content from GitHub
92        let file_content = self.github_client
93            .get_file_content(repo_owner, repo_name, ".meritocrab.toml")
94            .await?;
95
96        // Parse TOML
97        let config: RepoConfig = toml::from_str(&file_content).map_err(|e| {
98            warn!(
99                "Invalid .meritocrab.toml syntax for {}/{}: {}",
100                repo_owner, repo_name, e
101            );
102            ApiError::Internal(format!("Invalid TOML syntax: {}", e))
103        })?;
104
105        info!(
106            "Successfully loaded config for {}/{}: starting_credit={}, pr_threshold={}, blacklist_threshold={}",
107            repo_owner, repo_name, config.starting_credit, config.pr_threshold, config.blacklist_threshold
108        );
109
110        Ok(config)
111    }
112
113    /// Clear cache for a specific repository
114    #[allow(dead_code)]
115    pub async fn invalidate_cache(&self, repo_owner: &str, repo_name: &str) {
116        let cache_key = format!("{}/{}", repo_owner, repo_name);
117        let mut cache_guard = self.cache.write().await;
118        cache_guard.remove(&cache_key);
119        info!("Invalidated cache for {}/{}", repo_owner, repo_name);
120    }
121
122    /// Clear all cached configs
123    #[allow(dead_code)]
124    pub async fn clear_cache(&self) {
125        let mut cache_guard = self.cache.write().await;
126        cache_guard.clear();
127        info!("Cleared all config cache");
128    }
129
130    /// Get cache statistics (for monitoring)
131    #[allow(dead_code)]
132    pub async fn cache_size(&self) -> usize {
133        let cache_guard = self.cache.read().await;
134        cache_guard.len()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use meritocrab_github::GithubApiClient;
142
143    #[tokio::test]
144    async fn test_loader_returns_default_on_error() {
145        // Initialize rustls crypto provider for tests
146        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
147
148        // Create GitHub client (will fail when called)
149        let github_client = Arc::new(
150            GithubApiClient::new("test-token".to_string()).expect("Failed to create client")
151        );
152
153        let loader = RepoConfigLoader::new(github_client, 300);
154        let config = loader.get_config("owner", "repo").await;
155
156        // Should return defaults since GitHub fetch will fail
157        assert_eq!(config.starting_credit, 100);
158        assert_eq!(config.pr_threshold, 50);
159        assert_eq!(config.blacklist_threshold, 0);
160    }
161
162    #[tokio::test]
163    async fn test_cache_ttl() {
164        // Initialize rustls crypto provider for tests
165        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
166
167        let github_client = Arc::new(
168            GithubApiClient::new("test-token".to_string()).expect("Failed to create client")
169        );
170
171        // Very short TTL for testing
172        let loader = RepoConfigLoader::new(github_client, 1);
173
174        // First fetch (cache miss, will fail and return defaults, NOT cached)
175        let config1 = loader.get_config("owner", "repo").await;
176        // Cache is empty because fetch failed
177        assert_eq!(loader.cache_size().await, 0);
178
179        // Second fetch (cache miss again)
180        let config2 = loader.get_config("owner", "repo").await;
181        assert_eq!(config1.starting_credit, config2.starting_credit);
182
183        // Cache is still empty because fetches fail in tests
184        assert_eq!(loader.cache_size().await, 0);
185    }
186
187    #[tokio::test]
188    async fn test_invalidate_cache() {
189        // Initialize rustls crypto provider for tests
190        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
191
192        let github_client = Arc::new(
193            GithubApiClient::new("test-token".to_string()).expect("Failed to create client")
194        );
195
196        let loader = RepoConfigLoader::new(github_client, 300);
197
198        // Fetch config (will fail and not be cached)
199        let _config = loader.get_config("owner", "repo").await;
200        assert_eq!(loader.cache_size().await, 0);
201
202        // Invalidate (cache is already empty, this is a no-op)
203        loader.invalidate_cache("owner", "repo").await;
204        assert_eq!(loader.cache_size().await, 0);
205    }
206
207    #[tokio::test]
208    async fn test_clear_cache() {
209        // Initialize rustls crypto provider for tests
210        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
211
212        let github_client = Arc::new(
213            GithubApiClient::new("test-token".to_string()).expect("Failed to create client")
214        );
215
216        let loader = RepoConfigLoader::new(github_client, 300);
217
218        // Fetch multiple configs (will fail and not be cached)
219        let _config1 = loader.get_config("owner1", "repo1").await;
220        let _config2 = loader.get_config("owner2", "repo2").await;
221        assert_eq!(loader.cache_size().await, 0);
222
223        // Clear all (cache is already empty, this is a no-op)
224        loader.clear_cache().await;
225        assert_eq!(loader.cache_size().await, 0);
226    }
227}