pipedash_core/infrastructure/config/
schema.rs1use 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}