meritocrab_api/
repo_config_loader.rs1use 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#[derive(Debug, Clone)]
12struct CachedConfig {
13 config: RepoConfig,
14 fetched_at: Instant,
15}
16
17pub 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 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 pub async fn get_config(&self, repo_owner: &str, repo_name: &str) -> RepoConfig {
49 let cache_key = format!("{}/{}", repo_owner, repo_name);
50
51 {
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 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 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 async fn fetch_config_from_github(
91 &self,
92 repo_owner: &str,
93 repo_name: &str,
94 ) -> ApiResult<RepoConfig> {
95 let file_content = self
97 .github_client
98 .get_file_content(repo_owner, repo_name, ".meritocrab.toml")
99 .await?;
100
101 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 #[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 #[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 #[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 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
156
157 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 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 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 let loader = RepoConfigLoader::new(github_client, 1);
182
183 let config1 = loader.get_config("owner", "repo").await;
185 assert_eq!(loader.cache_size().await, 0);
187
188 let config2 = loader.get_config("owner", "repo").await;
190 assert_eq!(config1.starting_credit, config2.starting_credit);
191
192 assert_eq!(loader.cache_size().await, 0);
194 }
195
196 #[tokio::test]
197 async fn test_invalidate_cache() {
198 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 let _config = loader.get_config("owner", "repo").await;
209 assert_eq!(loader.cache_size().await, 0);
210
211 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 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 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 loader.clear_cache().await;
234 assert_eq!(loader.cache_size().await, 0);
235 }
236}