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 = true;
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    }
310
311    #[test]
312    fn test_storage_backend_parsing() {
313        assert_eq!(
314            "sqlite".parse::<StorageBackend>().unwrap(),
315            StorageBackend::Sqlite
316        );
317        assert_eq!(
318            "postgres".parse::<StorageBackend>().unwrap(),
319            StorageBackend::Postgres
320        );
321        assert_eq!(
322            "postgresql".parse::<StorageBackend>().unwrap(),
323            StorageBackend::Postgres
324        );
325    }
326
327    #[test]
328    fn test_storage_backend_helpers() {
329        assert!(StorageBackend::Postgres.requires_postgres());
330        assert!(!StorageBackend::Sqlite.requires_postgres());
331        assert!(StorageBackend::Sqlite.is_sqlite());
332        assert!(!StorageBackend::Postgres.is_sqlite());
333    }
334
335    #[test]
336    fn test_storage_config_validation() {
337        let sqlite_config = StorageConfig::default();
338        assert!(sqlite_config.validate().is_ok());
339
340        let pg_config = StorageConfig {
341            backend: StorageBackend::Postgres,
342            ..Default::default()
343        };
344        assert!(pg_config.validate().is_err());
345
346        let pg_config_with_conn = StorageConfig {
347            backend: StorageBackend::Postgres,
348            postgres: PostgresConfig {
349                connection_string: "postgres://localhost/test".to_string(),
350            },
351            ..Default::default()
352        };
353        assert!(pg_config_with_conn.validate().is_ok());
354    }
355
356    #[test]
357    fn test_provider_token_reference() {
358        let provider = ProviderFileConfig {
359            name: Some("Test Provider".to_string()),
360            provider_type: "github".to_string(),
361            token: "${GITHUB_TOKEN}".to_string(),
362            refresh_interval: 30,
363            config: HashMap::new(),
364        };
365
366        let token_ref = provider.token_reference().unwrap();
367        assert_eq!(
368            token_ref,
369            TokenReference::EnvVar("GITHUB_TOKEN".to_string())
370        );
371        assert!(provider.has_token());
372    }
373
374    #[test]
375    fn test_provider_display_name() {
376        let with_name = ProviderFileConfig {
377            name: Some("My GitHub".to_string()),
378            provider_type: "github".to_string(),
379            ..Default::default()
380        };
381        assert_eq!(with_name.display_name("github-main"), "My GitHub");
382
383        let without_name = ProviderFileConfig {
384            name: None,
385            provider_type: "github".to_string(),
386            ..Default::default()
387        };
388        assert_eq!(without_name.display_name("github-main"), "github-main");
389    }
390
391    #[test]
392    fn test_config_key_env_vars() {
393        assert_eq!(
394            ConfigKey::MetricsEnabled.env_var_name(),
395            "PIPEDASH_METRICS_ENABLED"
396        );
397        assert_eq!(
398            ConfigKey::StorageBackend.env_var_name(),
399            "PIPEDASH_STORAGE_BACKEND"
400        );
401        assert_eq!(
402            ConfigKey::PostgresConnectionString.env_var_name(),
403            "PIPEDASH_POSTGRES_URL"
404        );
405    }
406
407    #[test]
408    fn test_config_paths() {
409        let config = PipedashConfig::default();
410        let data_dir = config.data_dir();
411
412        assert!(config.db_path().starts_with(&data_dir));
413        assert!(config.metrics_db_path().starts_with(&data_dir));
414        assert!(config.cache_dir().starts_with(&data_dir));
415        assert!(config.vault_path().starts_with(&data_dir));
416    }
417}