1mod file_config;
4
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8const TEST_MODE_ENV_VAR: &str = "RESEARCH_MASTER_TEST_MODE";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CacheConfig {
13 #[serde(default)]
15 pub enabled: bool,
16
17 #[serde(default)]
19 pub directory: Option<PathBuf>,
20
21 #[serde(default = "default_search_ttl")]
23 pub search_ttl_seconds: u64,
24
25 #[serde(default = "default_citation_ttl")]
27 pub citation_ttl_seconds: u64,
28
29 #[serde(default = "default_max_cache_size")]
31 pub max_size_mb: usize,
32}
33
34impl Default for CacheConfig {
35 fn default() -> Self {
36 Self {
37 enabled: std::env::var("RESEARCH_MASTER_CACHE_ENABLED").is_ok(),
38 directory: None,
39 search_ttl_seconds: default_search_ttl(),
40 citation_ttl_seconds: default_citation_ttl(),
41 max_size_mb: default_max_cache_size(),
42 }
43 }
44}
45
46fn default_search_ttl() -> u64 {
47 1800 }
49
50fn default_citation_ttl() -> u64 {
51 900 }
53
54fn default_max_cache_size() -> usize {
55 500
56}
57
58pub fn default_cache_dir() -> PathBuf {
60 #[cfg(target_os = "macos")]
62 {
63 if let Ok(home) = std::env::var("HOME") {
64 return PathBuf::from(home)
65 .join("Library")
66 .join("Caches")
67 .join("research-master");
68 }
69 }
70
71 #[cfg(target_os = "linux")]
72 {
73 if let Ok(xdg_cache) = std::env::var("XDG_CACHE_HOME") {
74 return PathBuf::from(xdg_cache).join("research-master");
75 }
76 if let Ok(home) = std::env::var("HOME") {
77 return PathBuf::from(home).join(".cache").join("research-master");
78 }
79 }
80
81 #[cfg(target_os = "windows")]
82 {
83 if let Ok(appdata) = std::env::var("LOCALAPPDATA") {
84 return PathBuf::from(appdata).join("research-master").join("cache");
85 }
86 }
87
88 PathBuf::from(".research-master-cache")
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
94pub struct Config {
95 #[serde(default)]
97 pub api_keys: ApiKeys,
98
99 #[serde(default)]
101 pub downloads: DownloadConfig,
102
103 #[serde(default)]
105 pub rate_limits: RateLimitConfig,
106
107 #[serde(default)]
109 pub sources: SourceConfig,
110
111 #[serde(default)]
113 pub cache: CacheConfig,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct SourceConfig {
119 #[serde(default)]
122 pub enabled_sources: Option<String>,
123
124 #[serde(default)]
127 pub disabled_sources: Option<String>,
128
129 #[serde(default)]
132 pub proxy_http: Option<String>,
133
134 #[serde(default)]
137 pub proxy_https: Option<String>,
138
139 #[serde(default)]
143 pub rate_limits: Option<String>,
144}
145
146impl Default for SourceConfig {
147 fn default() -> Self {
148 Self::from_env()
149 }
150}
151
152impl SourceConfig {
153 fn from_env() -> Self {
154 Self {
155 enabled_sources: std::env::var("RESEARCH_MASTER_ENABLED_SOURCES").ok(),
156 disabled_sources: std::env::var("RESEARCH_MASTER_DISABLED_SOURCES").ok(),
157 proxy_http: std::env::var("RESEARCH_MASTER_PROXY_HTTP").ok(),
158 proxy_https: std::env::var("RESEARCH_MASTER_PROXY_HTTPS").ok(),
159 rate_limits: std::env::var("RESEARCH_MASTER_RATE_LIMITS").ok(),
160 }
161 }
162
163 fn without_env() -> Self {
164 Self {
165 enabled_sources: None,
166 disabled_sources: None,
167 proxy_http: None,
168 proxy_https: None,
169 rate_limits: None,
170 }
171 }
172
173 pub fn parse_rate_limits(&self) -> std::collections::HashMap<String, f32> {
177 let mut limits = std::collections::HashMap::new();
178
179 if let Some(ref limits_str) = self.rate_limits {
180 for part in limits_str.split(',') {
181 let parts: Vec<&str> = part.split(':').collect();
182 if parts.len() == 2 {
183 if let Ok(rate) = parts[1].parse::<f32>() {
184 limits.insert(parts[0].trim().to_string(), rate);
185 }
186 }
187 }
188 }
189
190 limits
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ApiKeys {
197 #[serde(default)]
199 pub semantic_scholar: Option<String>,
200
201 #[serde(default)]
203 pub core: Option<String>,
204}
205
206impl Default for ApiKeys {
207 fn default() -> Self {
208 Self::from_env()
209 }
210}
211
212impl ApiKeys {
213 fn from_env() -> Self {
214 Self {
215 semantic_scholar: std::env::var("SEMANTIC_SCHOLAR_API_KEY").ok(),
216 core: std::env::var("CORE_API_KEY").ok(),
217 }
218 }
219
220 fn without_env() -> Self {
221 Self {
222 semantic_scholar: None,
223 core: None,
224 }
225 }
226}
227
228impl Config {
229 fn from_env() -> Self {
230 Self {
231 api_keys: ApiKeys::from_env(),
232 downloads: DownloadConfig::default(),
233 rate_limits: RateLimitConfig::default(),
234 sources: SourceConfig::from_env(),
235 cache: CacheConfig::default(),
236 }
237 }
238
239 fn without_env() -> Self {
240 Self {
241 api_keys: ApiKeys::without_env(),
242 downloads: DownloadConfig::default(),
243 rate_limits: RateLimitConfig::default(),
244 sources: SourceConfig::without_env(),
245 cache: CacheConfig::default(),
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct DownloadConfig {
253 #[serde(default = "default_download_dir")]
255 pub default_path: PathBuf,
256
257 #[serde(default = "default_true")]
259 pub organize_by_source: bool,
260
261 #[serde(default = "default_max_file_size")]
263 pub max_file_size_mb: usize,
264}
265
266impl Default for DownloadConfig {
267 fn default() -> Self {
268 Self {
269 default_path: default_download_dir(),
270 organize_by_source: true,
271 max_file_size_mb: 100,
272 }
273 }
274}
275
276fn default_download_dir() -> PathBuf {
277 PathBuf::from("./downloads")
278}
279
280fn default_true() -> bool {
281 true
282}
283
284fn default_max_file_size() -> usize {
285 100
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct RateLimitConfig {
291 #[serde(default = "default_rps")]
293 pub default_requests_per_second: f32,
294
295 #[serde(default = "default_max_concurrent")]
297 pub max_concurrent_requests: usize,
298}
299
300impl Default for RateLimitConfig {
301 fn default() -> Self {
302 Self {
303 default_requests_per_second: default_rps(),
304 max_concurrent_requests: default_max_concurrent(),
305 }
306 }
307}
308
309fn default_rps() -> f32 {
310 5.0
311}
312
313fn default_max_concurrent() -> usize {
314 10
315}
316
317pub fn load_config(path: &Path) -> Result<Config, config::ConfigError> {
319 let test_mode = std::env::var(TEST_MODE_ENV_VAR)
320 .map(|value| value.eq_ignore_ascii_case("true"))
321 .unwrap_or(false);
322
323 if test_mode {
324 return Ok(Config::without_env());
325 }
326
327 let settings = config::Config::builder()
328 .add_source(config::File::from(path))
329 .add_source(config::Environment::with_prefix("RESEARCH_MASTER"))
330 .build()?;
331
332 settings.try_deserialize()
333}
334
335pub fn get_config() -> Config {
337 let test_mode = std::env::var(TEST_MODE_ENV_VAR)
338 .map(|value| value.eq_ignore_ascii_case("true"))
339 .unwrap_or(false);
340
341 if test_mode {
342 Config::without_env()
343 } else {
344 Config::from_env()
345 }
346}
347
348pub fn find_config_file() -> Option<PathBuf> {
358 let path = PathBuf::from("research-master.toml");
360 if path.exists() {
361 return Some(path);
362 }
363
364 let path = PathBuf::from(".research-master.toml");
366 if path.exists() {
367 return Some(path);
368 }
369
370 if let Ok(xdg_home) = std::env::var("XDG_CONFIG_HOME") {
372 let path = PathBuf::from(xdg_home)
373 .join("research-master")
374 .join("config.toml");
375 if path.exists() {
376 return Some(path);
377 }
378 }
379
380 if let Ok(home) = std::env::var("HOME") {
382 let home_path = PathBuf::from(&home);
383 let path = home_path
384 .join("Library")
385 .join("Application Support")
386 .join("research-master")
387 .join("config.toml");
388 if path.exists() {
389 return Some(path);
390 }
391
392 let path = home_path
394 .join(".config")
395 .join("research-master")
396 .join("config.toml");
397 if path.exists() {
398 return Some(path);
399 }
400 }
401
402 if let Ok(appdata) = std::env::var("APPDATA") {
404 let path = PathBuf::from(appdata)
405 .join("research-master")
406 .join("config.toml");
407 if path.exists() {
408 return Some(path);
409 }
410 }
411
412 None
413}
414
415pub use file_config::ConfigFile;
416pub use file_config::ConfigFileError;
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_default_config() {
424 let config = Config::default();
425 assert!(config.downloads.organize_by_source);
426 assert_eq!(config.rate_limits.default_requests_per_second, 5.0);
427 }
428
429 #[test]
430 fn test_config_without_env() {
431 let config = Config::without_env();
432 assert!(config.api_keys.semantic_scholar.is_none());
433 assert!(config.api_keys.core.is_none());
434 assert!(config.sources.enabled_sources.is_none());
435 assert!(config.sources.disabled_sources.is_none());
436 }
437
438 #[test]
439 fn test_cache_config_defaults() {
440 let cache = CacheConfig::default();
441 assert!(cache.search_ttl_seconds == 1800);
442 assert!(cache.citation_ttl_seconds == 900);
443 assert!(cache.max_size_mb == 500);
444 }
445
446 #[test]
447 fn test_download_config_defaults() {
448 let download = DownloadConfig::default();
449 assert!(download.organize_by_source);
450 assert_eq!(download.max_file_size_mb, 100);
451 }
452
453 #[test]
454 fn test_rate_limit_config_defaults() {
455 let rate = RateLimitConfig::default();
456 assert_eq!(rate.default_requests_per_second, 5.0);
457 assert_eq!(rate.max_concurrent_requests, 10);
458 }
459
460 #[test]
461 fn test_source_config_without_env() {
462 let source = SourceConfig::without_env();
463 assert!(source.enabled_sources.is_none());
464 assert!(source.disabled_sources.is_none());
465 assert!(source.proxy_http.is_none());
466 assert!(source.proxy_https.is_none());
467 assert!(source.rate_limits.is_none());
468 }
469
470 #[test]
471 fn test_api_keys_without_env() {
472 let keys = ApiKeys::without_env();
473 assert!(keys.semantic_scholar.is_none());
474 assert!(keys.core.is_none());
475 }
476
477 #[test]
478 fn test_parse_rate_limits() {
479 let source_config = SourceConfig {
480 rate_limits: Some("semantic:0.5,arxiv:5,openalex:2.5".to_string()),
481 ..Default::default()
482 };
483
484 let limits = source_config.parse_rate_limits();
485 assert_eq!(limits.get("semantic").copied(), Some(0.5));
486 assert_eq!(limits.get("arxiv").copied(), Some(5.0));
487 assert_eq!(limits.get("openalex").copied(), Some(2.5));
488 assert_eq!(limits.get("nonexistent"), None);
489 }
490
491 #[test]
492 fn test_parse_rate_limits_empty() {
493 let source_config = SourceConfig {
494 rate_limits: None,
495 ..Default::default()
496 };
497
498 let limits = source_config.parse_rate_limits();
499 assert!(limits.is_empty());
500 }
501
502 #[test]
503 fn test_parse_rate_limits_invalid_format() {
504 let source_config = SourceConfig {
505 rate_limits: Some("semantic:0.5,invalidformat,arxiv:5".to_string()),
506 ..Default::default()
507 };
508
509 let limits = source_config.parse_rate_limits();
510 assert_eq!(limits.get("semantic").copied(), Some(0.5));
511 assert_eq!(limits.get("arxiv").copied(), Some(5.0));
512 assert_eq!(limits.len(), 2);
514 }
515
516 #[test]
517 fn test_parse_rate_limits_whitespace() {
518 let source_config = SourceConfig {
520 rate_limits: Some("semantic:0.5,arxiv:5".to_string()),
521 ..Default::default()
522 };
523
524 let limits = source_config.parse_rate_limits();
525 assert_eq!(
526 limits.get("semantic").copied(),
527 Some(0.5),
528 "semantic rate should be 0.5"
529 );
530 assert_eq!(
531 limits.get("arxiv").copied(),
532 Some(5.0),
533 "arxiv rate should be 5.0"
534 );
535 }
536
537 #[test]
538 fn test_find_config_file_nonexistent() {
539 let result = find_config_file();
542 let _ = result;
545 }
546
547 #[test]
548 fn test_find_config_file_current_dir() {
549 }
553
554 #[test]
555 fn test_find_config_file_hidden() {
556 }
559
560 #[test]
561 fn test_get_config_test_mode() {
562 std::env::set_var(TEST_MODE_ENV_VAR, "true");
564
565 let config = get_config();
566 assert!(config.api_keys.semantic_scholar.is_none());
568
569 std::env::remove_var(TEST_MODE_ENV_VAR);
571 }
572
573 #[test]
574 fn test_load_config_test_mode() {
575 std::env::set_var(TEST_MODE_ENV_VAR, "true");
577
578 let result = load_config(Path::new("/nonexistent/path.toml"));
580 assert!(result.is_ok());
581 let config = result.unwrap();
582 assert!(config.api_keys.semantic_scholar.is_none());
583
584 std::env::remove_var(TEST_MODE_ENV_VAR);
586 }
587}