Skip to main content

facto_core/
config.rs

1use serde::Deserialize;
2use std::path::Path;
3
4#[derive(Debug, Clone, Default, Deserialize)]
5#[serde(default)]
6pub struct Config {
7    pub cache: CacheTtlConfig,
8    pub timeouts: TimeoutConfig,
9    pub registries: RegistriesConfig,
10    pub forges: ForgesConfig,
11    pub runtimes: RuntimesConfig,
12}
13
14#[derive(Debug, Clone, Deserialize)]
15#[serde(default)]
16pub struct CacheTtlConfig {
17    pub package_secs: u64,
18    pub search_secs: u64,
19    pub forge_secs: u64,
20    pub releases_secs: u64,
21    pub runtimes_secs: u64,
22}
23
24#[derive(Debug, Clone, Deserialize)]
25#[serde(default)]
26pub struct TimeoutConfig {
27    pub per_registry_secs: u64,
28    pub global_secs: u64,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(default)]
33pub struct RegistriesConfig {
34    pub enabled: Vec<String>,
35}
36
37#[derive(Clone, Deserialize)]
38#[serde(default)]
39pub struct ForgesConfig {
40    pub enabled: Vec<String>,
41    pub github_token: Option<String>,
42    pub gitlab_token: Option<String>,
43    pub gitlab_base_url: String,
44}
45
46impl std::fmt::Debug for ForgesConfig {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("ForgesConfig")
49            .field("enabled", &self.enabled)
50            .field("github_token", &self.github_token.as_ref().map(|_| "***"))
51            .field("gitlab_token", &self.gitlab_token.as_ref().map(|_| "***"))
52            .field("gitlab_base_url", &self.gitlab_base_url)
53            .finish()
54    }
55}
56
57#[derive(Debug, Clone, Deserialize)]
58#[serde(default)]
59pub struct RuntimesConfig {
60    pub enabled: Vec<String>,
61}
62
63impl Default for CacheTtlConfig {
64    fn default() -> Self {
65        Self {
66            package_secs: 900,
67            search_secs: 300,
68            forge_secs: 600,
69            releases_secs: 1800,
70            runtimes_secs: 21600,
71        }
72    }
73}
74
75impl Default for TimeoutConfig {
76    fn default() -> Self {
77        Self {
78            per_registry_secs: 5,
79            global_secs: 15,
80        }
81    }
82}
83
84impl Default for RegistriesConfig {
85    fn default() -> Self {
86        Self {
87            enabled: vec![
88                "pypi".into(),
89                "npm".into(),
90                "crates".into(),
91                "go".into(),
92                "rubygems".into(),
93                "maven".into(),
94                "nuget".into(),
95                "packagist".into(),
96                "dockerhub".into(),
97            ],
98        }
99    }
100}
101
102impl Default for ForgesConfig {
103    fn default() -> Self {
104        Self {
105            enabled: vec!["github".into(), "gitlab".into(), "codeberg".into()],
106            github_token: None,
107            gitlab_token: None,
108            gitlab_base_url: "https://gitlab.com".to_string(),
109        }
110    }
111}
112
113impl Default for RuntimesConfig {
114    fn default() -> Self {
115        Self {
116            enabled: vec![
117                "python".into(),
118                "go".into(),
119                "rust".into(),
120                "nodejs".into(),
121                "ruby".into(),
122                "php".into(),
123                "java".into(),
124                "dotnet".into(),
125                "deno".into(),
126                "bun".into(),
127                "elixir".into(),
128                "kotlin".into(),
129                "perl".into(),
130                "scala".into(),
131            ],
132        }
133    }
134}
135
136impl Config {
137    pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
138        let path_str = std::env::var("FACTO_CONFIG").unwrap_or_else(|_| "facto.toml".to_string());
139        let path = Path::new(&path_str);
140
141        let mut config = if path.exists() {
142            let content = std::fs::read_to_string(path)?;
143            toml::from_str(&content)?
144        } else {
145            Self::default()
146        };
147
148        config.apply_env_overrides();
149        config
150            .validate()
151            .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
152        Ok(config)
153    }
154
155    pub fn validate(&self) -> Result<(), String> {
156        if !self.forges.gitlab_base_url.starts_with("https://") {
157            return Err(format!(
158                "gitlab_base_url must use HTTPS, got: {}",
159                self.forges.gitlab_base_url
160            ));
161        }
162        Ok(())
163    }
164
165    pub fn apply_env_overrides(&mut self) {
166        if let Ok(token) = std::env::var("FACTO_GITHUB_TOKEN") {
167            self.forges.github_token = Some(token);
168        }
169        if let Ok(token) = std::env::var("FACTO_GITLAB_TOKEN") {
170            self.forges.gitlab_token = Some(token);
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_default_config() {
181        let config = Config::default();
182        assert_eq!(config.timeouts.per_registry_secs, 5);
183        assert!(config.registries.enabled.contains(&"pypi".to_string()));
184        assert!(config.forges.enabled.contains(&"github".to_string()));
185    }
186
187    #[test]
188    fn test_env_overrides_tokens() {
189        // SAFETY: test runs single-threaded via cargo test -- --test-threads=1
190        unsafe {
191            std::env::set_var("FACTO_GITHUB_TOKEN", "gh-test-token");
192            std::env::set_var("FACTO_GITLAB_TOKEN", "gl-test-token");
193        }
194
195        let mut config = Config::default();
196        config.apply_env_overrides();
197
198        assert_eq!(
199            config.forges.github_token,
200            Some("gh-test-token".to_string())
201        );
202        assert_eq!(
203            config.forges.gitlab_token,
204            Some("gl-test-token".to_string())
205        );
206
207        unsafe {
208            std::env::remove_var("FACTO_GITHUB_TOKEN");
209            std::env::remove_var("FACTO_GITLAB_TOKEN");
210        }
211    }
212
213    #[test]
214    fn test_debug_redacts_tokens() {
215        let config = ForgesConfig {
216            enabled: vec!["github".into()],
217            github_token: Some("ghp_secret123".to_string()),
218            gitlab_token: Some("glpat-secret456".to_string()),
219            gitlab_base_url: "https://gitlab.com".to_string(),
220        };
221        let debug = format!("{:?}", config);
222        assert!(
223            !debug.contains("ghp_secret123"),
224            "GitHub token leaked in Debug output"
225        );
226        assert!(
227            !debug.contains("glpat-secret456"),
228            "GitLab token leaked in Debug output"
229        );
230        assert!(debug.contains("***"), "Tokens should be redacted as ***");
231    }
232
233    #[test]
234    fn test_parse_partial_toml() {
235        let toml_str = r#"
236            [timeouts]
237            per_registry_secs = 10
238        "#;
239        let config: Config = toml::from_str(toml_str).unwrap();
240        assert_eq!(config.timeouts.per_registry_secs, 10);
241        assert_eq!(config.timeouts.global_secs, 15);
242    }
243
244    #[test]
245    fn test_gitlab_base_url_must_be_https() {
246        let mut config = Config::default();
247        config.forges.gitlab_base_url = "http://evil.com".to_string();
248        assert!(config.validate().is_err());
249    }
250
251    #[test]
252    fn test_gitlab_base_url_allows_https() {
253        let mut config = Config::default();
254        config.forges.gitlab_base_url = "https://gitlab.example.com".to_string();
255        assert!(config.validate().is_ok());
256    }
257
258    #[test]
259    fn test_gitlab_base_url_default_is_valid() {
260        let config = Config::default();
261        assert!(config.validate().is_ok());
262    }
263
264    #[test]
265    fn test_default_config_cache() {
266        let config = Config::default();
267        assert_eq!(config.cache.package_secs, 900);
268    }
269}