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
265#[derive(Debug, Clone, Serialize, Deserialize, Default)]
266pub struct PushConfig {
267    #[serde(default)]
268    pub relay_url: String,
269}
270
271impl PushConfig {
272    fn is_unset(&self) -> bool {
273        self.relay_url.is_empty()
274    }
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub struct ProviderFileConfig {
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub name: Option<String>,
281
282    #[serde(rename = "type")]
283    pub provider_type: String,
284
285    #[serde(default)]
286    pub token: String,
287
288    #[serde(default = "default_provider_refresh")]
289    pub refresh_interval: u32,
290
291    #[serde(default)]
292    pub config: HashMap<String, String>,
293}
294
295impl Default for ProviderFileConfig {
296    fn default() -> Self {
297        Self {
298            name: None,
299            provider_type: String::new(),
300            token: String::new(),
301            refresh_interval: default_provider_refresh(),
302            config: HashMap::new(),
303        }
304    }
305}
306
307fn default_provider_refresh() -> u32 {
308    DEFAULT_REFRESH_INTERVAL_SECS
309}
310
311impl ProviderFileConfig {
312    pub fn display_name<'a>(&'a self, id: &'a str) -> &'a str {
313        self.name.as_deref().unwrap_or(id)
314    }
315
316    pub fn token_reference(&self) -> Result<TokenReference, super::token_ref::TokenRefError> {
317        TokenReference::parse(&self.token)
318    }
319
320    pub fn has_token(&self) -> bool {
321        match self.token_reference() {
322            Ok(ref tr) => tr.is_configured(),
323            Err(_) => false,
324        }
325    }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
329pub enum ConfigKey {
330    MetricsEnabled,
331    DefaultRefreshInterval,
332    StatusPagesEnabled,
333    BindAddr,
334    CorsAllowAll,
335    DataDir,
336    StorageBackend,
337    PostgresConnectionString,
338    PushRelayUrl,
339}
340
341impl ConfigKey {
342    pub fn env_var_name(&self) -> &'static str {
343        match self {
344            Self::MetricsEnabled => "PIPEDASH_METRICS_ENABLED",
345            Self::DefaultRefreshInterval => "PIPEDASH_DEFAULT_REFRESH_INTERVAL",
346            Self::StatusPagesEnabled => "PIPEDASH_STATUS_PAGES_ENABLED",
347            Self::BindAddr => "PIPEDASH_BIND_ADDR",
348            Self::CorsAllowAll => "PIPEDASH_CORS_ALLOW_ALL",
349            Self::DataDir => "PIPEDASH_DATA_DIR",
350            Self::StorageBackend => "PIPEDASH_STORAGE_BACKEND",
351            Self::PostgresConnectionString => "PIPEDASH_POSTGRES_URL",
352            Self::PushRelayUrl => "PIPEDASH_PUSH_RELAY_URL",
353        }
354    }
355
356    pub fn is_storage_field(&self) -> bool {
357        matches!(self, Self::StorageBackend)
358    }
359
360    pub fn requires_restart(&self) -> bool {
361        matches!(self, Self::StorageBackend | Self::BindAddr | Self::DataDir)
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_default_config() {
371        let config = PipedashConfig::default();
372
373        assert!(config.general.metrics_enabled);
374        assert_eq!(config.general.default_refresh_interval, 30);
375        assert_eq!(config.server.bind_addr, "127.0.0.1:8080");
376        assert_eq!(config.storage.backend, StorageBackend::Sqlite);
377        assert!(config.providers.is_empty());
378        assert!(!config.server.cors_allow_all);
379        assert!(config.general.status_pages_enabled);
380    }
381
382    #[test]
383    fn test_status_pages_default_to_enabled_when_absent() {
384        let config: PipedashConfig = toml::from_str("[general]\nmetrics_enabled = false").unwrap();
385        let disabled: PipedashConfig =
386            toml::from_str("[general]\nstatus_pages_enabled = false").unwrap();
387
388        assert!(config.general.status_pages_enabled);
389        assert!(!disabled.general.status_pages_enabled);
390        assert!(GeneralConfig::default().status_pages_enabled);
391    }
392
393    #[test]
394    fn test_cors_allow_all_defaults_to_false_when_absent() {
395        let config: PipedashConfig =
396            toml::from_str("[server]\nbind_addr = \"0.0.0.0:8080\"").unwrap();
397
398        assert!(!config.server.cors_allow_all);
399        assert!(!ServerConfig::default().cors_allow_all);
400    }
401
402    #[test]
403    fn test_push_relay_url_is_unset_by_default_and_read_from_the_push_table() {
404        let unset: PipedashConfig =
405            toml::from_str("[server]\nbind_addr = \"0.0.0.0:8080\"").unwrap();
406        let set: PipedashConfig =
407            toml::from_str("[push]\nrelay_url = \"https://relay.example.com\"").unwrap();
408
409        assert!(unset.push.relay_url.is_empty());
410        assert!(!toml::to_string(&unset).unwrap().contains("[push]"));
411        assert_eq!(set.push.relay_url, "https://relay.example.com");
412        assert!(toml::to_string(&set).unwrap().contains("relay_url"));
413        assert_eq!(
414            ConfigKey::PushRelayUrl.env_var_name(),
415            "PIPEDASH_PUSH_RELAY_URL"
416        );
417    }
418
419    #[test]
420    fn test_storage_backend_parsing() {
421        assert_eq!(
422            "sqlite".parse::<StorageBackend>().unwrap(),
423            StorageBackend::Sqlite
424        );
425        assert_eq!(
426            "postgres".parse::<StorageBackend>().unwrap(),
427            StorageBackend::Postgres
428        );
429        assert_eq!(
430            "postgresql".parse::<StorageBackend>().unwrap(),
431            StorageBackend::Postgres
432        );
433    }
434
435    #[test]
436    fn test_storage_backend_helpers() {
437        assert!(StorageBackend::Postgres.requires_postgres());
438        assert!(!StorageBackend::Sqlite.requires_postgres());
439        assert!(StorageBackend::Sqlite.is_sqlite());
440        assert!(!StorageBackend::Postgres.is_sqlite());
441    }
442
443    #[test]
444    fn test_storage_config_validation() {
445        let sqlite_config = StorageConfig::default();
446        assert!(sqlite_config.validate().is_ok());
447
448        let pg_config = StorageConfig {
449            backend: StorageBackend::Postgres,
450            ..Default::default()
451        };
452        assert!(pg_config.validate().is_err());
453
454        let pg_config_with_conn = StorageConfig {
455            backend: StorageBackend::Postgres,
456            postgres: PostgresConfig {
457                connection_string: "postgres://localhost/test".to_string(),
458            },
459            ..Default::default()
460        };
461        assert!(pg_config_with_conn.validate().is_ok());
462    }
463
464    #[test]
465    fn test_provider_token_reference() {
466        let provider = ProviderFileConfig {
467            name: Some("Test Provider".to_string()),
468            provider_type: "github".to_string(),
469            token: "${GITHUB_TOKEN}".to_string(),
470            refresh_interval: 30,
471            config: HashMap::new(),
472        };
473
474        let token_ref = provider.token_reference().unwrap();
475        assert_eq!(
476            token_ref,
477            TokenReference::EnvVar("GITHUB_TOKEN".to_string())
478        );
479        assert!(provider.has_token());
480    }
481
482    #[test]
483    fn test_provider_display_name() {
484        let with_name = ProviderFileConfig {
485            name: Some("My GitHub".to_string()),
486            provider_type: "github".to_string(),
487            ..Default::default()
488        };
489        assert_eq!(with_name.display_name("github-main"), "My GitHub");
490
491        let without_name = ProviderFileConfig {
492            name: None,
493            provider_type: "github".to_string(),
494            ..Default::default()
495        };
496        assert_eq!(without_name.display_name("github-main"), "github-main");
497    }
498
499    #[test]
500    fn test_config_key_env_vars() {
501        assert_eq!(
502            ConfigKey::MetricsEnabled.env_var_name(),
503            "PIPEDASH_METRICS_ENABLED"
504        );
505        assert_eq!(
506            ConfigKey::StorageBackend.env_var_name(),
507            "PIPEDASH_STORAGE_BACKEND"
508        );
509        assert_eq!(
510            ConfigKey::PostgresConnectionString.env_var_name(),
511            "PIPEDASH_POSTGRES_URL"
512        );
513    }
514
515    #[test]
516    fn test_config_paths() {
517        let config = PipedashConfig::default();
518        let data_dir = config.data_dir();
519
520        assert!(config.db_path().starts_with(&data_dir));
521        assert!(config.metrics_db_path().starts_with(&data_dir));
522        assert!(config.cache_dir().starts_with(&data_dir));
523        assert!(config.vault_path().starts_with(&data_dir));
524    }
525}