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