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;
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}