pipedash_core/infrastructure/config/
schema.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use indexmap::IndexMap;
5use serde::{
6 Deserialize,
7 Serialize,
8};
9
10use super::token_ref::TokenReference;
11
12pub(super) const DEFAULT_REFRESH_INTERVAL_SECS: u32 = 30;
13
14pub const MIN_REFRESH_INTERVAL_SECS: u32 = 5;
15
16pub const MAX_REFRESH_INTERVAL_SECS: u32 = 300;
17
18pub(super) const DEFAULT_BIND_ADDR_DESKTOP: &str = "127.0.0.1:8080";
19
20pub(super) const DEFAULT_BIND_ADDR_SERVER: &str = "0.0.0.0:8080";
21
22pub(super) const DEFAULT_CORS_ALLOW_ALL: bool = false;
23
24pub(super) const DEFAULT_METRICS_ENABLED: bool = true;
25
26pub(super) const DEFAULT_DATA_DIR_SERVER: &str = "./data";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "lowercase")]
30pub enum StorageBackend {
31 #[default]
32 Sqlite,
33 Postgres,
34}
35
36impl StorageBackend {
37 pub fn requires_postgres(&self) -> bool {
38 matches!(self, Self::Postgres)
39 }
40
41 pub fn is_sqlite(&self) -> bool {
42 matches!(self, Self::Sqlite)
43 }
44}
45
46impl std::fmt::Display for StorageBackend {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::Sqlite => write!(f, "sqlite"),
50 Self::Postgres => write!(f, "postgres"),
51 }
52 }
53}
54
55impl std::str::FromStr for StorageBackend {
56 type Err = String;
57
58 fn from_str(s: &str) -> Result<Self, Self::Err> {
59 match s.to_lowercase().as_str() {
60 "sqlite" => Ok(Self::Sqlite),
61 "postgres" | "postgresql" => Ok(Self::Postgres),
62 _ => Err(format!(
63 "Unknown storage backend: {}. Valid options: sqlite, postgres",
64 s
65 )),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct PipedashConfig {
72 #[serde(default)]
73 pub general: GeneralConfig,
74
75 #[serde(default)]
76 pub server: ServerConfig,
77
78 #[serde(default)]
79 pub storage: StorageConfig,
80
81 #[serde(default)]
82 pub providers: IndexMap<String, ProviderFileConfig>,
83}
84
85impl PipedashConfig {
86 pub fn data_dir(&self) -> PathBuf {
87 if self.storage.data_dir.is_empty() {
88 Self::default_data_dir()
89 } else {
90 PathBuf::from(&self.storage.data_dir)
91 }
92 }
93
94 pub fn default_data_dir() -> PathBuf {
95 std::env::var("PIPEDASH_DATA_DIR")
96 .map(PathBuf::from)
97 .unwrap_or_else(|_| {
98 dirs::data_dir()
99 .map(|p| p.join("pipedash"))
100 .unwrap_or_else(|| PathBuf::from(".pipedash"))
101 })
102 }
103
104 pub fn db_path(&self) -> PathBuf {
105 self.data_dir().join("pipedash.db")
106 }
107
108 pub fn metrics_db_path(&self) -> PathBuf {
109 self.data_dir().join("metrics.db")
110 }
111
112 pub fn cache_dir(&self) -> PathBuf {
113 self.data_dir().join("cache")
114 }
115
116 pub fn vault_path(&self) -> PathBuf {
117 self.data_dir().join("vault.db")
118 }
119
120 pub fn config_path(&self) -> PathBuf {
121 self.data_dir().join("config.toml")
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct GeneralConfig {
127 #[serde(default = "default_metrics_enabled")]
128 pub metrics_enabled: bool,
129
130 #[serde(default = "default_refresh_interval")]
131 pub default_refresh_interval: u32,
132
133 #[serde(default = "default_true")]
134 pub status_pages_enabled: bool,
135}
136
137impl Default for GeneralConfig {
138 fn default() -> Self {
139 Self {
140 metrics_enabled: default_metrics_enabled(),
141 default_refresh_interval: default_refresh_interval(),
142 status_pages_enabled: default_true(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub struct GeneralSettingsUpdate {
149 pub metrics_enabled: bool,
150 pub default_refresh_interval: u32,
151 pub status_pages_enabled: bool,
152}
153
154fn default_metrics_enabled() -> bool {
155 DEFAULT_METRICS_ENABLED
156}
157
158fn default_refresh_interval() -> u32 {
159 DEFAULT_REFRESH_INTERVAL_SECS
160}
161
162fn default_true() -> bool {
163 true
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ServerConfig {
168 #[serde(default = "default_bind_addr")]
169 pub bind_addr: String,
170
171 #[serde(default = "default_cors_allow_all")]
172 pub cors_allow_all: bool,
173
174 #[serde(default)]
175 pub base_path: String,
176}
177
178impl Default for ServerConfig {
179 fn default() -> Self {
180 Self {
181 bind_addr: default_bind_addr(),
182 cors_allow_all: default_cors_allow_all(),
183 base_path: String::new(),
184 }
185 }
186}
187
188fn default_bind_addr() -> String {
189 DEFAULT_BIND_ADDR_DESKTOP.to_string()
190}
191
192fn default_cors_allow_all() -> bool {
193 DEFAULT_CORS_ALLOW_ALL
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, Default)]
197pub struct StorageConfig {
198 #[serde(default, skip_serializing_if = "String::is_empty")]
199 pub data_dir: String,
200
201 #[serde(default)]
202 pub backend: StorageBackend,
203
204 #[serde(default, skip_serializing_if = "is_default_postgres_config")]
205 pub postgres: PostgresConfig,
206
207 #[serde(default, skip_serializing)]
208 pub vault_password: Option<String>,
209}
210
211fn is_default_postgres_config(c: &PostgresConfig) -> bool {
212 c.connection_string.is_empty()
213}
214
215impl StorageConfig {
216 pub fn validate(&self) -> Result<(), String> {
217 if self.backend.requires_postgres() && self.postgres.connection_string.is_empty() {
218 return Err("PostgreSQL backend selected but connection_string is empty".to_string());
219 }
220
221 Ok(())
222 }
223
224 pub fn summary(&self) -> String {
225 format!("Storage: {} backend", self.backend)
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230pub struct PostgresConfig {
231 #[serde(default)]
232 pub connection_string: String,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ProviderFileConfig {
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub name: Option<String>,
239
240 #[serde(rename = "type")]
241 pub provider_type: String,
242
243 #[serde(default)]
244 pub token: String,
245
246 #[serde(default = "default_provider_refresh")]
247 pub refresh_interval: u32,
248
249 #[serde(default)]
250 pub config: HashMap<String, String>,
251}
252
253impl Default for ProviderFileConfig {
254 fn default() -> Self {
255 Self {
256 name: None,
257 provider_type: String::new(),
258 token: String::new(),
259 refresh_interval: default_provider_refresh(),
260 config: HashMap::new(),
261 }
262 }
263}
264
265fn default_provider_refresh() -> u32 {
266 DEFAULT_REFRESH_INTERVAL_SECS
267}
268
269impl ProviderFileConfig {
270 pub fn display_name<'a>(&'a self, id: &'a str) -> &'a str {
271 self.name.as_deref().unwrap_or(id)
272 }
273
274 pub fn token_reference(&self) -> Result<TokenReference, super::token_ref::TokenRefError> {
275 TokenReference::parse(&self.token)
276 }
277
278 pub fn has_token(&self) -> bool {
279 match self.token_reference() {
280 Ok(ref tr) => tr.is_configured(),
281 Err(_) => false,
282 }
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
287pub enum ConfigKey {
288 MetricsEnabled,
289 DefaultRefreshInterval,
290 StatusPagesEnabled,
291 BindAddr,
292 CorsAllowAll,
293 DataDir,
294 StorageBackend,
295 PostgresConnectionString,
296}
297
298impl ConfigKey {
299 pub fn env_var_name(&self) -> &'static str {
300 match self {
301 Self::MetricsEnabled => "PIPEDASH_METRICS_ENABLED",
302 Self::DefaultRefreshInterval => "PIPEDASH_DEFAULT_REFRESH_INTERVAL",
303 Self::StatusPagesEnabled => "PIPEDASH_STATUS_PAGES_ENABLED",
304 Self::BindAddr => "PIPEDASH_BIND_ADDR",
305 Self::CorsAllowAll => "PIPEDASH_CORS_ALLOW_ALL",
306 Self::DataDir => "PIPEDASH_DATA_DIR",
307 Self::StorageBackend => "PIPEDASH_STORAGE_BACKEND",
308 Self::PostgresConnectionString => "PIPEDASH_POSTGRES_URL",
309 }
310 }
311
312 pub fn is_storage_field(&self) -> bool {
313 matches!(self, Self::StorageBackend)
314 }
315
316 pub fn requires_restart(&self) -> bool {
317 matches!(self, Self::StorageBackend | Self::BindAddr | Self::DataDir)
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn test_default_config() {
327 let config = PipedashConfig::default();
328
329 assert!(config.general.metrics_enabled);
330 assert_eq!(config.general.default_refresh_interval, 30);
331 assert_eq!(config.server.bind_addr, "127.0.0.1:8080");
332 assert_eq!(config.storage.backend, StorageBackend::Sqlite);
333 assert!(config.providers.is_empty());
334 assert!(!config.server.cors_allow_all);
335 assert!(config.general.status_pages_enabled);
336 }
337
338 #[test]
339 fn test_status_pages_default_to_enabled_when_absent() {
340 let config: PipedashConfig = toml::from_str("[general]\nmetrics_enabled = false").unwrap();
341 let disabled: PipedashConfig =
342 toml::from_str("[general]\nstatus_pages_enabled = false").unwrap();
343
344 assert!(config.general.status_pages_enabled);
345 assert!(!disabled.general.status_pages_enabled);
346 assert!(GeneralConfig::default().status_pages_enabled);
347 }
348
349 #[test]
350 fn test_cors_allow_all_defaults_to_false_when_absent() {
351 let config: PipedashConfig =
352 toml::from_str("[server]\nbind_addr = \"0.0.0.0:8080\"").unwrap();
353
354 assert!(!config.server.cors_allow_all);
355 assert!(!ServerConfig::default().cors_allow_all);
356 }
357
358 #[test]
359 fn test_storage_backend_parsing() {
360 assert_eq!(
361 "sqlite".parse::<StorageBackend>().unwrap(),
362 StorageBackend::Sqlite
363 );
364 assert_eq!(
365 "postgres".parse::<StorageBackend>().unwrap(),
366 StorageBackend::Postgres
367 );
368 assert_eq!(
369 "postgresql".parse::<StorageBackend>().unwrap(),
370 StorageBackend::Postgres
371 );
372 }
373
374 #[test]
375 fn test_storage_backend_helpers() {
376 assert!(StorageBackend::Postgres.requires_postgres());
377 assert!(!StorageBackend::Sqlite.requires_postgres());
378 assert!(StorageBackend::Sqlite.is_sqlite());
379 assert!(!StorageBackend::Postgres.is_sqlite());
380 }
381
382 #[test]
383 fn test_storage_config_validation() {
384 let sqlite_config = StorageConfig::default();
385 assert!(sqlite_config.validate().is_ok());
386
387 let pg_config = StorageConfig {
388 backend: StorageBackend::Postgres,
389 ..Default::default()
390 };
391 assert!(pg_config.validate().is_err());
392
393 let pg_config_with_conn = StorageConfig {
394 backend: StorageBackend::Postgres,
395 postgres: PostgresConfig {
396 connection_string: "postgres://localhost/test".to_string(),
397 },
398 ..Default::default()
399 };
400 assert!(pg_config_with_conn.validate().is_ok());
401 }
402
403 #[test]
404 fn test_provider_token_reference() {
405 let provider = ProviderFileConfig {
406 name: Some("Test Provider".to_string()),
407 provider_type: "github".to_string(),
408 token: "${GITHUB_TOKEN}".to_string(),
409 refresh_interval: 30,
410 config: HashMap::new(),
411 };
412
413 let token_ref = provider.token_reference().unwrap();
414 assert_eq!(
415 token_ref,
416 TokenReference::EnvVar("GITHUB_TOKEN".to_string())
417 );
418 assert!(provider.has_token());
419 }
420
421 #[test]
422 fn test_provider_display_name() {
423 let with_name = ProviderFileConfig {
424 name: Some("My GitHub".to_string()),
425 provider_type: "github".to_string(),
426 ..Default::default()
427 };
428 assert_eq!(with_name.display_name("github-main"), "My GitHub");
429
430 let without_name = ProviderFileConfig {
431 name: None,
432 provider_type: "github".to_string(),
433 ..Default::default()
434 };
435 assert_eq!(without_name.display_name("github-main"), "github-main");
436 }
437
438 #[test]
439 fn test_config_key_env_vars() {
440 assert_eq!(
441 ConfigKey::MetricsEnabled.env_var_name(),
442 "PIPEDASH_METRICS_ENABLED"
443 );
444 assert_eq!(
445 ConfigKey::StorageBackend.env_var_name(),
446 "PIPEDASH_STORAGE_BACKEND"
447 );
448 assert_eq!(
449 ConfigKey::PostgresConnectionString.env_var_name(),
450 "PIPEDASH_POSTGRES_URL"
451 );
452 }
453
454 #[test]
455 fn test_config_paths() {
456 let config = PipedashConfig::default();
457 let data_dir = config.data_dir();
458
459 assert!(config.db_path().starts_with(&data_dir));
460 assert!(config.metrics_db_path().starts_with(&data_dir));
461 assert!(config.cache_dir().starts_with(&data_dir));
462 assert!(config.vault_path().starts_with(&data_dir));
463 }
464}