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}