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