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