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 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}