pipedash_core/infrastructure/config/
loader.rs1use std::path::{
2 Path,
3 PathBuf,
4};
5
6use serde::{
7 Deserialize,
8 Serialize,
9};
10use thiserror::Error;
11use toml_edit::{
12 DocumentMut,
13 Item,
14};
15
16use super::interpolation::{
17 interpolate_toml,
18 InterpolationError,
19};
20use super::schema::PipedashConfig;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SetupStatus {
24 pub config_exists: bool,
25 pub config_valid: bool,
26 pub validation_errors: Vec<String>,
27 pub needs_setup: bool,
28 pub needs_migration: bool,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub database_exists: Option<bool>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub database_path: Option<String>,
33}
34
35#[derive(Debug, Error)]
36pub enum ConfigLoadError {
37 #[error("Config file not found: {0}")]
38 FileNotFound(PathBuf),
39
40 #[error("Failed to read config file: {0}")]
41 ReadError(#[from] std::io::Error),
42
43 #[error("Failed to parse TOML: {0}")]
44 ParseError(#[from] toml::de::Error),
45
46 #[error("Environment variable interpolation failed: {0}")]
47 InterpolationError(#[from] InterpolationError),
48
49 #[error("Invalid configuration: {0}")]
50 InvalidConfig(String),
51}
52
53pub type ConfigLoadResult<T> = Result<T, ConfigLoadError>;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Platform {
57 Desktop,
58 Server,
59}
60
61impl Platform {
62 pub fn detect() -> Self {
63 if std::env::var("PIPEDASH_SERVER_MODE").is_ok()
64 || std::env::var("PIPEDASH_API_MODE").is_ok()
65 {
66 return Self::Server;
67 }
68
69 if std::env::var("KUBERNETES_SERVICE_HOST").is_ok()
70 || std::env::var("DOCKER_CONTAINER").is_ok()
71 || std::env::var("PIPEDASH_DATA_DIR")
72 .map(|d| d.starts_with("/data") || d.starts_with("/var"))
73 .unwrap_or(false)
74 {
75 return Self::Server;
76 }
77
78 Self::Desktop
79 }
80}
81
82pub struct ConfigLoader;
83
84impl ConfigLoader {
85 pub fn discover_config_path() -> PathBuf {
86 if let Ok(path) = std::env::var("PIPEDASH_CONFIG_PATH") {
87 tracing::debug!("Using config path from PIPEDASH_CONFIG_PATH: {}", path);
88 return PathBuf::from(path);
89 }
90
91 let platform = Platform::detect();
92
93 match platform {
94 Platform::Desktop => {
95 if let Some(config_dir) = dirs::config_dir() {
96 let path = config_dir.join("pipedash").join("config.toml");
97 if path.exists() {
98 tracing::debug!("Using desktop config path: {}", path.display());
99 return path;
100 }
101 }
102 }
103 Platform::Server => {
104 let path = PathBuf::from("/etc/pipedash/config.toml");
105 if path.exists() {
106 tracing::debug!("Using server config path: {}", path.display());
107 return path;
108 }
109 }
110 }
111
112 let fallback = PipedashConfig::default_data_dir().join("config.toml");
113 tracing::debug!("Using fallback config path: {}", fallback.display());
114 fallback
115 }
116
117 pub fn load_default() -> ConfigLoadResult<PipedashConfig> {
118 let path = Self::discover_config_path();
119 Self::load(&path)
120 }
121
122 pub fn load(path: &Path) -> ConfigLoadResult<PipedashConfig> {
123 if !path.exists() {
124 return Err(ConfigLoadError::FileNotFound(path.to_path_buf()));
125 }
126
127 let content = std::fs::read_to_string(path)?;
128 Self::parse(&content)
129 }
130
131 pub fn load_or_create(path: &Path, platform: Platform) -> ConfigLoadResult<PipedashConfig> {
132 if path.exists() {
133 Self::load(path)
134 } else {
135 let config = Self::create_default_config(platform);
136
137 if let Some(parent) = path.parent() {
138 std::fs::create_dir_all(parent)?;
139 }
140
141 Self::save(&config, path)?;
142 tracing::info!("Created default config at {:?}", path);
143
144 Ok(config)
145 }
146 }
147
148 fn create_default_config(platform: Platform) -> PipedashConfig {
149 use super::schema::{
150 DEFAULT_BIND_ADDR_SERVER,
151 DEFAULT_DATA_DIR_SERVER,
152 };
153
154 let mut config = PipedashConfig::default();
155
156 match platform {
157 Platform::Desktop => {}
158 Platform::Server => {
159 config.server.bind_addr = DEFAULT_BIND_ADDR_SERVER.to_string();
160 config.storage.data_dir = DEFAULT_DATA_DIR_SERVER.to_string();
161 }
162 }
163
164 config
165 }
166
167 pub fn parse(content: &str) -> ConfigLoadResult<PipedashConfig> {
168 let mut value: toml::Value = toml::from_str(content)?;
169
170 interpolate_toml(&mut value)?;
171
172 let config: PipedashConfig = value.try_into().map_err(|e| {
173 ConfigLoadError::InvalidConfig(format!("Failed to deserialize config: {}", e))
174 })?;
175
176 tracing::debug!(
177 backend = %config.storage.backend,
178 "Loaded config"
179 );
180
181 Ok(config)
182 }
183
184 pub fn parse_raw(content: &str) -> ConfigLoadResult<PipedashConfig> {
185 let config: PipedashConfig = toml::from_str(content)?;
186 Ok(config)
187 }
188
189 pub fn config_exists(path: &Path) -> bool {
190 path.exists()
191 }
192
193 pub fn get_setup_status(data_dir: &Path) -> SetupStatus {
194 let config_path = data_dir.join("config.toml");
195 let legacy_path = data_dir.join("storage_config.json");
196
197 if legacy_path.exists() && !config_path.exists() {
198 return SetupStatus {
199 config_exists: false,
200 config_valid: false,
201 validation_errors: vec![],
202 needs_setup: false,
203 needs_migration: true,
204 database_exists: None,
205 database_path: None,
206 };
207 }
208
209 if !config_path.exists() {
210 return SetupStatus {
211 config_exists: false,
212 config_valid: false,
213 validation_errors: vec![],
214 needs_setup: true,
215 needs_migration: false,
216 database_exists: None,
217 database_path: None,
218 };
219 }
220
221 match Self::load(&config_path) {
222 Ok(config) => {
223 let validation = config.validate();
224 let errors: Vec<String> = validation.errors.iter().map(|e| e.to_string()).collect();
225
226 let db_path = config.db_path();
227 let db_exists = db_path.exists();
228
229 SetupStatus {
230 config_exists: true,
231 config_valid: errors.is_empty(),
232 validation_errors: errors,
233 needs_setup: false,
234 needs_migration: false,
235 database_exists: Some(db_exists),
236 database_path: Some(db_path.display().to_string()),
237 }
238 }
239 Err(e) => SetupStatus {
240 config_exists: true,
241 config_valid: false,
242 validation_errors: vec![format!("Failed to parse config: {}", e)],
243 needs_setup: false,
244 needs_migration: false,
245 database_exists: None,
246 database_path: None,
247 },
248 }
249 }
250
251 pub fn to_toml(config: &PipedashConfig) -> Result<String, toml::ser::Error> {
252 toml::to_string_pretty(config)
253 }
254
255 pub fn save(config: &PipedashConfig, path: &Path) -> ConfigLoadResult<()> {
256 if path.exists() {
257 let content = std::fs::read_to_string(path)?;
258 let mut doc = content.parse::<DocumentMut>().map_err(|e| {
259 ConfigLoadError::InvalidConfig(format!("Failed to parse existing config: {}", e))
260 })?;
261
262 Self::update_document(&mut doc, config)?;
263
264 std::fs::write(path, doc.to_string())?;
265 } else {
266 let toml_str = Self::to_toml(config).map_err(|e| {
267 ConfigLoadError::InvalidConfig(format!("Failed to serialize config: {}", e))
268 })?;
269
270 if let Some(parent) = path.parent() {
271 std::fs::create_dir_all(parent)?;
272 }
273
274 std::fs::write(path, toml_str)?;
275 }
276
277 Ok(())
278 }
279
280 fn update_document(doc: &mut DocumentMut, config: &PipedashConfig) -> ConfigLoadResult<()> {
281 if let Some(general) = doc.get_mut("general").and_then(|v| v.as_table_like_mut()) {
282 general.insert(
283 "metrics_enabled",
284 Item::Value(config.general.metrics_enabled.into()),
285 );
286 general.insert(
287 "default_refresh_interval",
288 Item::Value((config.general.default_refresh_interval as i64).into()),
289 );
290 }
291
292 if let Some(server) = doc.get_mut("server").and_then(|v| v.as_table_like_mut()) {
293 server.insert(
294 "bind_addr",
295 Item::Value(config.server.bind_addr.clone().into()),
296 );
297 server.insert(
298 "cors_allow_all",
299 Item::Value(config.server.cors_allow_all.into()),
300 );
301 }
302
303 if doc.get("storage").is_none() {
304 doc.insert("storage", Item::Table(toml_edit::Table::new()));
305 }
306
307 if let Some(storage) = doc.get_mut("storage").and_then(|v| v.as_table_like_mut()) {
308 storage.insert(
309 "backend",
310 Item::Value(config.storage.backend.to_string().into()),
311 );
312
313 if !config.storage.data_dir.is_empty() {
314 storage.insert(
315 "data_dir",
316 Item::Value(config.storage.data_dir.clone().into()),
317 );
318 }
319
320 if !config.storage.postgres.connection_string.is_empty() {
321 if storage.get("postgres").is_none() {
322 storage.insert("postgres", Item::Table(toml_edit::Table::new()));
323 }
324 if let Some(postgres) = storage
325 .get_mut("postgres")
326 .and_then(|v| v.as_table_like_mut())
327 {
328 postgres.insert(
329 "connection_string",
330 Item::Value(config.storage.postgres.connection_string.clone().into()),
331 );
332 }
333 }
334 }
335
336 Ok(())
337 }
338
339 pub fn get_sources(path: &Path) -> ConfigLoadResult<ConfigSources> {
340 let content = if path.exists() {
341 std::fs::read_to_string(path)?
342 } else {
343 String::new()
344 };
345
346 let file_config = if !content.is_empty() {
347 Some(Self::parse_raw(&content)?)
348 } else {
349 None
350 };
351
352 let resolved = if path.exists() {
353 Self::load(path)?
354 } else {
355 PipedashConfig::default()
356 };
357
358 Ok(ConfigSources {
359 resolved,
360 file_config,
361 config_path: path.to_path_buf(),
362 })
363 }
364}
365
366#[derive(Debug)]
367pub struct ConfigSources {
368 pub resolved: PipedashConfig,
369 pub file_config: Option<PipedashConfig>,
370 pub config_path: PathBuf,
371}
372
373impl ConfigSources {
374 pub fn get_source(&self, key: &str) -> ValueSource {
375 let env_var_name = match key {
376 "general.metrics_enabled" => Some("PIPEDASH_METRICS_ENABLED"),
377 "general.default_refresh_interval" => Some("PIPEDASH_DEFAULT_REFRESH_INTERVAL"),
378 "server.bind_addr" => Some("PIPEDASH_BIND_ADDR"),
379 "storage.data_dir" => Some("PIPEDASH_DATA_DIR"),
380 "storage.backend" => Some("PIPEDASH_STORAGE_BACKEND"),
381 "storage.postgres.connection_string" => Some("PIPEDASH_POSTGRES_URL"),
382 _ => None,
383 };
384
385 if let Some(var) = env_var_name {
386 if std::env::var(var).is_ok() {
387 return ValueSource::EnvVar(var.to_string());
388 }
389 }
390
391 if self.file_config.is_some() {
392 return ValueSource::ConfigFile(self.config_path.clone());
393 }
394
395 ValueSource::Default
396 }
397}
398
399#[derive(Debug, Clone)]
400pub enum ValueSource {
401 EnvVar(String),
402 ConfigFile(PathBuf),
403 Default,
404}
405
406impl std::fmt::Display for ValueSource {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 match self {
409 Self::EnvVar(name) => write!(f, "env: {}", name),
410 Self::ConfigFile(path) => write!(f, "file: {}", path.display()),
411 Self::Default => write!(f, "default"),
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use tempfile::TempDir;
419
420 use super::*;
421
422 #[test]
423 fn test_platform_detect_default() {
424 let platform = Platform::detect();
425 assert!(matches!(platform, Platform::Desktop | Platform::Server));
426 }
427
428 #[test]
429 fn test_parse_simple_config() {
430 let content = r#"
431[general]
432metrics_enabled = true
433default_refresh_interval = 60
434
435[storage]
436backend = "sqlite"
437"#;
438
439 let config = ConfigLoader::parse(content).unwrap();
440 assert!(config.general.metrics_enabled);
441 assert_eq!(config.general.default_refresh_interval, 60);
442 }
443
444 #[test]
445 fn test_parse_with_providers() {
446 let content = r#"
447[providers.my-github]
448name = "My GitHub"
449type = "github"
450token = "GITHUB_TOKEN"
451refresh_interval = 30
452
453[providers.my-github.config]
454base_url = ""
455"#;
456
457 let config = ConfigLoader::parse(content).unwrap();
458 assert_eq!(config.providers.len(), 1);
459 assert!(config.providers.contains_key("my-github"));
460 assert_eq!(
461 config.providers["my-github"].name,
462 Some("My GitHub".to_string())
463 );
464 assert_eq!(config.providers["my-github"].provider_type, "github");
465 }
466
467 #[test]
468 fn test_load_or_create() {
469 let temp_dir = TempDir::new().unwrap();
470 let config_path = temp_dir.path().join("config.toml");
471
472 let config = ConfigLoader::load_or_create(&config_path, Platform::Desktop).unwrap();
473 assert!(config_path.exists());
474 assert!(config.general.metrics_enabled);
475
476 let config2 = ConfigLoader::load(&config_path).unwrap();
477 assert!(config2.general.metrics_enabled);
478 }
479
480 #[test]
481 fn test_env_var_interpolation() {
482 std::env::set_var("TEST_BIND_ADDR", "0.0.0.0:9999");
483
484 let content = r#"
485[server]
486bind_addr = "${TEST_BIND_ADDR}"
487"#;
488
489 let config = ConfigLoader::parse(content).unwrap();
490 assert_eq!(config.server.bind_addr, "0.0.0.0:9999");
491
492 std::env::remove_var("TEST_BIND_ADDR");
493 }
494
495 #[test]
496 fn test_env_var_with_default() {
497 let content = r#"
498[server]
499bind_addr = "${NONEXISTENT_VAR:-127.0.0.1:8080}"
500"#;
501
502 let config = ConfigLoader::parse(content).unwrap();
503 assert_eq!(config.server.bind_addr, "127.0.0.1:8080");
504 }
505
506 #[test]
507 fn test_to_toml() {
508 let config = PipedashConfig::default();
509 let toml_str = ConfigLoader::to_toml(&config).unwrap();
510
511 assert!(toml_str.contains("[general]"));
512 assert!(toml_str.contains("metrics_enabled"));
513 }
514
515 #[test]
516 fn test_discover_config_path_env_override() {
517 std::env::set_var("PIPEDASH_CONFIG_PATH", "/custom/path/config.toml");
518 let path = ConfigLoader::discover_config_path();
519 assert_eq!(path, PathBuf::from("/custom/path/config.toml"));
520 std::env::remove_var("PIPEDASH_CONFIG_PATH");
521 }
522}