Skip to main content

pipedash_core/infrastructure/config/
schema.rs

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