pipedash_core/infrastructure/config/
loader.rs1use 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}