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 = 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}