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