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}