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