Skip to main content

pipedash_core/infrastructure/config/
loader.rs

1use 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            "server.cors_allow_all" => Some("PIPEDASH_CORS_ALLOW_ALL"),
380            "storage.data_dir" => Some("PIPEDASH_DATA_DIR"),
381            "storage.backend" => Some("PIPEDASH_STORAGE_BACKEND"),
382            "storage.postgres.connection_string" => Some("PIPEDASH_POSTGRES_URL"),
383            _ => None,
384        };
385
386        if let Some(var) = env_var_name {
387            if std::env::var(var).is_ok() {
388                return ValueSource::EnvVar(var.to_string());
389            }
390        }
391
392        if self.file_config.is_some() {
393            return ValueSource::ConfigFile(self.config_path.clone());
394        }
395
396        ValueSource::Default
397    }
398}
399
400#[derive(Debug, Clone)]
401pub enum ValueSource {
402    EnvVar(String),
403    ConfigFile(PathBuf),
404    Default,
405}
406
407impl std::fmt::Display for ValueSource {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        match self {
410            Self::EnvVar(name) => write!(f, "env: {}", name),
411            Self::ConfigFile(path) => write!(f, "file: {}", path.display()),
412            Self::Default => write!(f, "default"),
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use tempfile::TempDir;
420
421    use super::*;
422    use crate::test_env::EnvGuard;
423
424    #[test]
425    fn test_platform_detect_default() {
426        let platform = Platform::detect();
427        assert!(matches!(platform, Platform::Desktop | Platform::Server));
428    }
429
430    #[test]
431    fn test_parse_simple_config() {
432        let content = r#"
433[general]
434metrics_enabled = true
435default_refresh_interval = 60
436
437[storage]
438backend = "sqlite"
439"#;
440
441        let config = ConfigLoader::parse(content).unwrap();
442        assert!(config.general.metrics_enabled);
443        assert_eq!(config.general.default_refresh_interval, 60);
444    }
445
446    #[test]
447    fn test_parse_with_providers() {
448        let content = r#"
449[providers.my-github]
450name = "My GitHub"
451type = "github"
452token = "GITHUB_TOKEN"
453refresh_interval = 30
454
455[providers.my-github.config]
456base_url = ""
457"#;
458
459        let config = ConfigLoader::parse(content).unwrap();
460        assert_eq!(config.providers.len(), 1);
461        assert!(config.providers.contains_key("my-github"));
462        assert_eq!(
463            config.providers["my-github"].name,
464            Some("My GitHub".to_string())
465        );
466        assert_eq!(config.providers["my-github"].provider_type, "github");
467    }
468
469    #[test]
470    fn test_load_or_create() {
471        let temp_dir = TempDir::new().unwrap();
472        let config_path = temp_dir.path().join("config.toml");
473
474        let config = ConfigLoader::load_or_create(&config_path, Platform::Desktop).unwrap();
475        assert!(config_path.exists());
476        assert!(config.general.metrics_enabled);
477
478        let config2 = ConfigLoader::load(&config_path).unwrap();
479        assert!(config2.general.metrics_enabled);
480    }
481
482    #[test]
483    fn test_env_var_interpolation() {
484        std::env::set_var("TEST_BIND_ADDR", "0.0.0.0:9999");
485
486        let content = r#"
487[server]
488bind_addr = "${TEST_BIND_ADDR}"
489"#;
490
491        let config = ConfigLoader::parse(content).unwrap();
492        assert_eq!(config.server.bind_addr, "0.0.0.0:9999");
493
494        std::env::remove_var("TEST_BIND_ADDR");
495    }
496
497    #[test]
498    fn test_env_var_with_default() {
499        let content = r#"
500[server]
501bind_addr = "${NONEXISTENT_VAR:-127.0.0.1:8080}"
502"#;
503
504        let config = ConfigLoader::parse(content).unwrap();
505        assert_eq!(config.server.bind_addr, "127.0.0.1:8080");
506    }
507
508    #[test]
509    fn test_to_toml() {
510        let config = PipedashConfig::default();
511        let toml_str = ConfigLoader::to_toml(&config).unwrap();
512
513        assert!(toml_str.contains("[general]"));
514        assert!(toml_str.contains("metrics_enabled"));
515    }
516
517    #[test]
518    fn test_discover_config_path_env_override() {
519        let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", Some("/custom/path/config.toml"))]);
520        let path = ConfigLoader::discover_config_path();
521        assert_eq!(path, PathBuf::from("/custom/path/config.toml"));
522    }
523}