1use std::path::PathBuf;
2use std::sync::{
3 Arc,
4 OnceLock,
5};
6
7use tokio::sync::{
8 broadcast,
9 RwLock,
10};
11
12use super::loader::{
13 ConfigLoader,
14 Platform,
15};
16use super::schema::{
17 ConfigKey,
18 GeneralSettingsUpdate,
19 PipedashConfig,
20 ProviderFileConfig,
21 MAX_REFRESH_INTERVAL_SECS,
22 MIN_REFRESH_INTERVAL_SECS,
23};
24use super::sync::{
25 ProviderSyncService,
26 SyncResult,
27};
28use super::token_ref::TokenReference;
29use super::validation::ValidationResult;
30use crate::application::{
31 HealthHandle,
32 HealthInput,
33};
34use crate::domain::{
35 DomainError,
36 DomainResult,
37};
38use crate::infrastructure::http_client::parse_proxy_url;
39use crate::infrastructure::{
40 ConfigBackend,
41 TokenStore,
42};
43
44#[derive(Debug, Clone)]
45pub enum ConfigChangeEvent {
46 Reloaded {
47 changed_keys: Vec<String>,
48 },
49 ValueChanged {
50 key: String,
51 old_value: Option<String>,
52 new_value: String,
53 },
54 ProviderAdded {
55 name: String,
56 },
57 ProviderUpdated {
58 name: String,
59 },
60 ProviderRemoved {
61 name: String,
62 },
63}
64
65impl ConfigChangeEvent {
66 pub fn summary(&self) -> String {
67 match self {
68 Self::Reloaded { changed_keys } => {
69 format!("Config reloaded, {} keys changed", changed_keys.len())
70 }
71 Self::ValueChanged { key, .. } => format!("Value changed: {}", key),
72 Self::ProviderAdded { name } => format!("Provider added: {}", name),
73 Self::ProviderUpdated { name } => format!("Provider updated: {}", name),
74 Self::ProviderRemoved { name } => format!("Provider removed: {}", name),
75 }
76 }
77}
78
79pub struct ConfigState {
80 config: RwLock<Arc<PipedashConfig>>,
81
82 config_path: PathBuf,
83
84 change_tx: broadcast::Sender<ConfigChangeEvent>,
85
86 token_store: Arc<dyn TokenStore>,
87
88 platform: Platform,
89
90 sync_service: ProviderSyncService,
91
92 health: OnceLock<HealthHandle>,
93}
94
95impl ConfigState {
96 pub async fn initialize(
97 data_dir: &std::path::Path, token_store: Arc<dyn TokenStore>,
98 ) -> DomainResult<Arc<Self>> {
99 let platform = Platform::detect();
100
101 let config_path = ConfigLoader::discover_config_path();
102 let legacy_path = data_dir.join("storage_config.json");
103
104 if legacy_path.exists() && !config_path.exists() {
105 let migrated_config =
106 super::migration::ConfigMigrator::migrate_if_needed(data_dir).await?;
107 return Self::from_config_impl(migrated_config, config_path, token_store);
108 }
109
110 let config = ConfigLoader::load_or_create(&config_path, platform)
111 .map_err(|e| DomainError::InvalidConfig(format!("Failed to load config: {}", e)))?;
112
113 let validation = config.validate();
114 for error in &validation.errors {
115 tracing::error!("Config error: {}", error);
116 }
117 for warning in &validation.warnings {
118 tracing::warn!("Config warning: {}", warning);
119 }
120
121 let (change_tx, _) = broadcast::channel(16);
122
123 let state = Arc::new(Self {
124 config: RwLock::new(Arc::new(config)),
125 config_path,
126 change_tx,
127 token_store,
128 platform,
129 sync_service: ProviderSyncService::new(),
130 health: OnceLock::new(),
131 });
132
133 Ok(state)
134 }
135
136 pub fn from_config(
137 config: PipedashConfig, config_path: PathBuf, token_store: Arc<dyn TokenStore>,
138 ) -> Arc<Self> {
139 let (change_tx, _) = broadcast::channel(16);
140
141 Arc::new(Self {
142 config: RwLock::new(Arc::new(config)),
143 config_path,
144 change_tx,
145 token_store,
146 platform: Platform::detect(),
147 sync_service: ProviderSyncService::new(),
148 health: OnceLock::new(),
149 })
150 }
151
152 fn from_config_impl(
153 config: PipedashConfig, config_path: PathBuf, token_store: Arc<dyn TokenStore>,
154 ) -> DomainResult<Arc<Self>> {
155 Ok(Self::from_config(config, config_path, token_store))
156 }
157
158 pub async fn get(&self) -> Arc<PipedashConfig> {
159 self.config.read().await.clone()
160 }
161
162 pub fn config_path(&self) -> &PathBuf {
163 &self.config_path
164 }
165
166 pub fn token_store(&self) -> Arc<dyn TokenStore> {
167 Arc::clone(&self.token_store)
168 }
169
170 pub fn platform(&self) -> Platform {
171 self.platform
172 }
173
174 pub fn subscribe(&self) -> broadcast::Receiver<ConfigChangeEvent> {
175 self.change_tx.subscribe()
176 }
177
178 pub fn attach_health(&self, health: HealthHandle) {
179 let _ = self.health.set(health);
180 }
181
182 fn notify_settings_changed(&self) {
183 if let Some(health) = self.health.get() {
184 health.send(HealthInput::SettingsChanged);
185 }
186 }
187
188 pub async fn resolve_token(
189 &self, token_ref: &TokenReference, provider_id: Option<i64>,
190 ) -> DomainResult<String> {
191 token_ref
192 .resolve(self.token_store.as_ref(), provider_id)
193 .await
194 .map_err(|e| DomainError::InvalidConfig(format!("Token resolution failed: {}", e)))
195 }
196
197 pub async fn reload(&self) -> DomainResult<ConfigChangeEvent> {
198 let new_config = ConfigLoader::load(&self.config_path)
199 .map_err(|e| DomainError::InvalidConfig(format!("Failed to reload config: {}", e)))?;
200
201 let validation = new_config.validate();
202 if !validation.errors.is_empty() {
203 return Err(DomainError::InvalidConfig(format!(
204 "Config validation failed: {:?}",
205 validation.errors
206 )));
207 }
208
209 *self.config.write().await = Arc::new(new_config);
210
211 let event = ConfigChangeEvent::Reloaded {
212 changed_keys: vec![], };
214
215 let _ = self.change_tx.send(event.clone());
216 self.notify_settings_changed();
217
218 tracing::info!("Config reloaded from {:?}", self.config_path);
219 Ok(event)
220 }
221
222 pub async fn validate(&self) -> ValidationResult {
223 self.config.read().await.validate()
224 }
225
226 pub async fn add_provider(&self, id: String, provider: ProviderFileConfig) -> DomainResult<()> {
227 if !provider.token.is_empty() {
228 TokenReference::parse(&provider.token)
229 .map_err(|e| DomainError::InvalidConfig(format!("Invalid token format: {}", e)))?;
230 }
231
232 let current_config = self.config.read().await;
233 let mut config = (**current_config).clone();
234 drop(current_config);
235
236 if config.providers.contains_key(&id) {
237 return Err(DomainError::InvalidConfig(format!(
238 "Provider '{}' already exists",
239 id
240 )));
241 }
242
243 config.providers.insert(id.clone(), provider);
244
245 self.persist_config(&config).await?;
246
247 let event = ConfigChangeEvent::ProviderAdded { name: id };
248 let _ = self.change_tx.send(event.clone());
249
250 Ok(())
251 }
252
253 pub async fn update_provider(
254 &self, id: &str, provider: ProviderFileConfig,
255 ) -> DomainResult<()> {
256 if !provider.token.is_empty() {
257 TokenReference::parse(&provider.token)
258 .map_err(|e| DomainError::InvalidConfig(format!("Invalid token format: {}", e)))?;
259 }
260
261 let current_config = self.config.read().await;
262 let mut config = (**current_config).clone();
263 drop(current_config);
264
265 if !config.providers.contains_key(id) {
266 return Err(DomainError::InvalidConfig(format!(
267 "Provider '{}' not found",
268 id
269 )));
270 }
271
272 config.providers.insert(id.to_string(), provider);
273
274 self.persist_config(&config).await?;
275
276 let event = ConfigChangeEvent::ProviderUpdated {
277 name: id.to_string(),
278 };
279 let _ = self.change_tx.send(event.clone());
280
281 Ok(())
282 }
283
284 pub async fn remove_provider(&self, id: &str) -> DomainResult<()> {
285 let content = match std::fs::read_to_string(&self.config_path) {
286 Ok(content) => Some(content),
287 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
288 Err(e) => {
289 return Err(DomainError::DatabaseError(format!(
290 "Failed to read config file: {e}"
291 )))
292 }
293 };
294 if let Some(content) = content {
295 let mut doc: toml_edit::DocumentMut = content.parse().map_err(|e| {
296 DomainError::DatabaseError(format!("Failed to parse existing TOML: {e}"))
297 })?;
298 if doc
299 .get_mut("providers")
300 .and_then(toml_edit::Item::as_table_like_mut)
301 .and_then(|providers| providers.remove(id))
302 .is_some()
303 {
304 std::fs::write(&self.config_path, doc.to_string()).map_err(|e| {
305 DomainError::DatabaseError(format!("Failed to write config file: {e}"))
306 })?;
307 }
308 }
309
310 let mut current = self.config.write().await;
311 if current.providers.contains_key(id) {
312 let mut config = (**current).clone();
313 config.providers.shift_remove(id);
314 *current = Arc::new(config);
315 }
316
317 let event = ConfigChangeEvent::ProviderRemoved {
318 name: id.to_string(),
319 };
320 let _ = self.change_tx.send(event);
321 Ok(())
322 }
323
324 pub async fn update_general_settings(&self, update: GeneralSettingsUpdate) -> DomainResult<()> {
325 if !(MIN_REFRESH_INTERVAL_SECS..=MAX_REFRESH_INTERVAL_SECS)
326 .contains(&update.default_refresh_interval)
327 {
328 return Err(DomainError::InvalidConfig(format!(
329 "default_refresh_interval must be between {MIN_REFRESH_INTERVAL_SECS} and \
330 {MAX_REFRESH_INTERVAL_SECS} seconds"
331 )));
332 }
333 let proxy_url = parse_proxy_url(&update.proxy_url)?
334 .map(|_| update.proxy_url.trim().to_string())
335 .unwrap_or_default();
336
337 let current_config = self.config.read().await;
338 let mut config = (**current_config).clone();
339 drop(current_config);
340
341 config.general.metrics_enabled = update.metrics_enabled;
342 config.general.default_refresh_interval = update.default_refresh_interval;
343 config.general.status_pages_enabled = update.status_pages_enabled;
344 config.general.proxy_url = proxy_url;
345
346 self.persist_general(&config).await?;
347
348 let _ = self.change_tx.send(ConfigChangeEvent::ValueChanged {
349 key: "general".to_string(),
350 old_value: None,
351 new_value: "updated".to_string(),
352 });
353 self.notify_settings_changed();
354
355 Ok(())
356 }
357
358 pub async fn sync_providers_from_toml(
359 &self, config_backend: &dyn ConfigBackend, delete_orphans: bool,
360 ) -> DomainResult<SyncResult> {
361 let config = self.config.read().await;
362 self.sync_service
363 .sync_toml_to_db(
364 config.providers.clone(),
365 config_backend,
366 self.token_store.as_ref(),
367 delete_orphans,
368 )
369 .await
370 }
371
372 pub fn load_file(&self) -> DomainResult<PipedashConfig> {
373 let file = ConfigLoader::load(&self.config_path)
374 .map_err(|e| DomainError::InvalidConfig(format!("Failed to reload config: {}", e)))?;
375
376 let validation = file.validate();
377 if !validation.errors.is_empty() {
378 return Err(DomainError::InvalidConfig(format!(
379 "Config validation failed: {:?}",
380 validation.errors
381 )));
382 }
383
384 Ok(file)
385 }
386
387 pub fn load_providers(&self) -> DomainResult<indexmap::IndexMap<String, ProviderFileConfig>> {
388 self.load_file().map(|file| file.providers)
389 }
390
391 pub async fn remember_provider(&self, id: &str, provider: &ProviderFileConfig) {
392 let mut config = self.config.write().await;
393 let mut next = (**config).clone();
394 next.providers.insert(id.to_string(), provider.clone());
395 *config = Arc::new(next);
396 }
397
398 pub async fn remember_providers(
399 &self, providers: indexmap::IndexMap<String, ProviderFileConfig>,
400 ) {
401 let mut config = self.config.write().await;
402 let mut next = (**config).clone();
403 next.providers = providers;
404 *config = Arc::new(next);
405 }
406
407 pub async fn remember_views(
408 &self, views: indexmap::IndexMap<String, super::schema::ViewFileConfig>,
409 ) {
410 let mut config = self.config.write().await;
411 let mut next = (**config).clone();
412 next.views = views;
413 *config = Arc::new(next);
414 }
415
416 async fn persist_config(&self, config: &PipedashConfig) -> DomainResult<()> {
417 let existing_content = std::fs::read_to_string(&self.config_path).ok();
418
419 let new_content = if let Some(existing) = existing_content {
420 Self::update_toml_preserving_comments(&existing, config)?
421 } else {
422 ConfigLoader::to_toml(config).map_err(|e| {
423 DomainError::DatabaseError(format!("Failed to serialize config: {}", e))
424 })?
425 };
426
427 std::fs::write(&self.config_path, &new_content).map_err(|e| {
428 DomainError::DatabaseError(format!("Failed to write config file: {}", e))
429 })?;
430
431 *self.config.write().await = Arc::new(config.clone());
432
433 tracing::debug!("Config persisted to {:?}", self.config_path);
434 Ok(())
435 }
436
437 async fn persist_general(&self, config: &PipedashConfig) -> DomainResult<()> {
438 let content = match std::fs::read_to_string(&self.config_path) {
439 Ok(existing) => {
440 let mut doc: toml_edit::DocumentMut = existing.parse().map_err(|e| {
441 DomainError::DatabaseError(format!("Failed to parse existing TOML: {}", e))
442 })?;
443 ConfigLoader::apply_general(&mut doc, &config.general);
444 doc.to_string()
445 }
446 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ConfigLoader::to_toml(config)
447 .map_err(|e| {
448 DomainError::DatabaseError(format!("Failed to serialize config: {}", e))
449 })?,
450 Err(e) => {
451 return Err(DomainError::DatabaseError(format!(
452 "Failed to read config file: {}",
453 e
454 )))
455 }
456 };
457
458 std::fs::write(&self.config_path, content).map_err(|e| {
459 DomainError::DatabaseError(format!("Failed to write config file: {}", e))
460 })?;
461
462 *self.config.write().await = Arc::new(config.clone());
463
464 tracing::debug!("General settings persisted to {:?}", self.config_path);
465 Ok(())
466 }
467
468 pub(super) fn update_toml_preserving_comments(
469 existing: &str, config: &PipedashConfig,
470 ) -> DomainResult<String> {
471 use toml_edit::{
472 value,
473 DocumentMut,
474 };
475
476 let mut doc: DocumentMut = existing.parse().map_err(|e| {
477 DomainError::DatabaseError(format!("Failed to parse existing TOML: {}", e))
478 })?;
479
480 ConfigLoader::apply_general(&mut doc, &config.general);
481
482 if let Some(server) = doc.get_mut("server") {
483 if let Some(table) = server.as_table_mut() {
484 table["bind_addr"] = value(&config.server.bind_addr);
485 table["cors_allow_all"] = value(config.server.cors_allow_all);
486 }
487 }
488
489 Self::write_providers(&mut doc, &config.providers);
490
491 Ok(doc.to_string())
492 }
493
494 pub(super) fn write_providers(
495 doc: &mut toml_edit::DocumentMut,
496 providers: &indexmap::IndexMap<String, ProviderFileConfig>,
497 ) {
498 use toml_edit::{
499 value,
500 Item,
501 };
502
503 doc.remove("providers");
504
505 if providers.is_empty() {
506 return;
507 }
508
509 let mut providers_table = toml_edit::Table::new();
510
511 for (id, provider) in providers {
512 let mut provider_table = toml_edit::Table::new();
513
514 if let Some(name) = &provider.name {
515 provider_table["name"] = value(name);
516 }
517
518 provider_table["type"] = value(&provider.provider_type);
519 provider_table["token"] = value(&provider.token);
520 provider_table["refresh_interval"] = value(provider.refresh_interval as i64);
521
522 if !provider.config.is_empty() {
523 let mut config_table = toml_edit::Table::new();
524 let mut entries: Vec<_> = provider.config.iter().collect();
525 entries.sort();
526 for (k, v) in entries {
527 config_table[k] = value(v);
528 }
529 provider_table["config"] = Item::Table(config_table);
530 }
531
532 providers_table[id] = Item::Table(provider_table);
533 }
534
535 doc["providers"] = Item::Table(providers_table);
536 }
537
538 pub async fn get_string(&self, key: ConfigKey) -> String {
539 if let Ok(val) = std::env::var(key.env_var_name()) {
540 return val;
541 }
542
543 let config = self.config.read().await;
544 match key {
545 ConfigKey::MetricsEnabled => config.general.metrics_enabled.to_string(),
546 ConfigKey::DefaultRefreshInterval => {
547 config.general.default_refresh_interval.to_string()
548 }
549 ConfigKey::StatusPagesEnabled => config.general.status_pages_enabled.to_string(),
550 ConfigKey::BindAddr => config.server.bind_addr.clone(),
551 ConfigKey::CorsAllowAll => config.server.cors_allow_all.to_string(),
552 ConfigKey::DataDir => {
553 if config.storage.data_dir.is_empty() {
554 PipedashConfig::default_data_dir()
555 .to_string_lossy()
556 .to_string()
557 } else {
558 config.storage.data_dir.clone()
559 }
560 }
561 ConfigKey::StorageBackend => config.storage.backend.to_string(),
562 ConfigKey::PostgresConnectionString => {
563 config.storage.postgres.connection_string.clone()
564 }
565 ConfigKey::PushRelayUrl => config.push.relay_url.clone(),
566 }
567 }
568
569 pub async fn get_bool(&self, key: ConfigKey) -> bool {
570 let val = self.get_string(key).await;
571 matches!(val.to_lowercase().as_str(), "true" | "1" | "yes")
572 }
573
574 pub async fn get_u32(&self, key: ConfigKey) -> u32 {
575 self.get_string(key).await.parse().unwrap_or(0)
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use tempfile::TempDir;
582
583 use super::*;
584 use crate::{
585 infrastructure::MemoryTokenStore,
586 test_env::EnvGuard,
587 };
588
589 fn create_test_state(temp_dir: &TempDir, token_store: Arc<dyn TokenStore>) -> Arc<ConfigState> {
590 let config_path = temp_dir.path().join("config.toml");
591 let config = PipedashConfig::default();
592 ConfigState::from_config(config, config_path, token_store)
593 }
594
595 #[tokio::test]
596 async fn test_config_state_from_config() {
597 let temp_dir = TempDir::new().unwrap();
598 let token_store = Arc::new(MemoryTokenStore::new());
599
600 let state = create_test_state(&temp_dir, token_store);
601
602 let config = state.get().await;
603 assert!(config.general.metrics_enabled);
604 }
605
606 #[tokio::test]
607 async fn test_add_remove_provider() {
608 let temp_dir = TempDir::new().unwrap();
609 let token_store = Arc::new(MemoryTokenStore::new());
610
611 let state = create_test_state(&temp_dir, token_store);
612
613 let provider = ProviderFileConfig {
614 name: Some("Test GitHub".to_string()),
615 provider_type: "github".to_string(),
616 token: "${GITHUB_TOKEN:-}".to_string(),
617 refresh_interval: 30,
618 config: std::collections::HashMap::new(),
619 };
620
621 state
622 .add_provider("test-github".to_string(), provider)
623 .await
624 .unwrap();
625
626 let config = state.get().await;
627 assert_eq!(config.providers.len(), 1);
628 assert!(config.providers.contains_key("test-github"));
629
630 state.remove_provider("test-github").await.unwrap();
631
632 let config = state.get().await;
633 assert!(config.providers.is_empty());
634 }
635
636 #[tokio::test]
637 async fn test_reload_replaces_in_memory_config() {
638 let temp_dir = TempDir::new().unwrap();
639 let token_store = Arc::new(MemoryTokenStore::new());
640
641 let state = create_test_state(&temp_dir, token_store);
642
643 let mut on_disk = PipedashConfig::default();
644 on_disk.general.default_refresh_interval = 123;
645 std::fs::write(
646 temp_dir.path().join("config.toml"),
647 ConfigLoader::to_toml(&on_disk).unwrap(),
648 )
649 .unwrap();
650
651 state.reload().await.unwrap();
652
653 assert_eq!(state.get().await.general.default_refresh_interval, 123);
654 }
655
656 #[tokio::test]
657 async fn test_subscribe_to_changes() {
658 let temp_dir = TempDir::new().unwrap();
659 let token_store = Arc::new(MemoryTokenStore::new());
660
661 let state = create_test_state(&temp_dir, token_store);
662
663 let mut rx = state.subscribe();
664
665 let provider = ProviderFileConfig {
666 name: None,
667 provider_type: "github".to_string(),
668 token: String::new(),
669 refresh_interval: 30,
670 config: std::collections::HashMap::new(),
671 };
672
673 state
674 .add_provider("test".to_string(), provider)
675 .await
676 .unwrap();
677
678 let event = rx.try_recv().unwrap();
679 assert!(matches!(event, ConfigChangeEvent::ProviderAdded { .. }));
680 }
681
682 #[tokio::test]
683 async fn test_persist_and_reload() {
684 let temp_dir = TempDir::new().unwrap();
685 let token_store = Arc::new(MemoryTokenStore::new());
686 let config_path = temp_dir.path().join("config.toml");
687
688 let state = ConfigState::from_config(
689 PipedashConfig::default(),
690 config_path.clone(),
691 token_store.clone(),
692 );
693
694 let provider = ProviderFileConfig {
695 name: Some("Persist Test".to_string()),
696 provider_type: "gitlab".to_string(),
697 token: "${GITLAB_TOKEN:-}".to_string(),
698 refresh_interval: 60,
699 config: std::collections::HashMap::new(),
700 };
701
702 state
703 .add_provider("persist-test".to_string(), provider)
704 .await
705 .unwrap();
706
707 let loaded_config = ConfigLoader::load(&config_path).unwrap();
708 assert_eq!(loaded_config.providers.len(), 1);
709 assert!(loaded_config.providers.contains_key("persist-test"));
710 assert_eq!(loaded_config.providers["persist-test"].refresh_interval, 60);
711 }
712
713 #[tokio::test]
714 async fn test_get_config_values() {
715 let _env = EnvGuard::set(&[
716 ("PIPEDASH_METRICS_ENABLED", None),
717 ("PIPEDASH_DEFAULT_REFRESH_INTERVAL", None),
718 ]);
719
720 let temp_dir = TempDir::new().unwrap();
721 let token_store = Arc::new(MemoryTokenStore::new());
722
723 let state = create_test_state(&temp_dir, token_store);
724
725 assert!(state.get_bool(ConfigKey::MetricsEnabled).await);
726 assert_eq!(state.get_u32(ConfigKey::DefaultRefreshInterval).await, 30);
727 }
728
729 #[tokio::test]
730 async fn test_env_var_override() {
731 let _env = EnvGuard::set(&[("PIPEDASH_METRICS_ENABLED", Some("false"))]);
732
733 let temp_dir = TempDir::new().unwrap();
734 let token_store = Arc::new(MemoryTokenStore::new());
735
736 let state = create_test_state(&temp_dir, token_store);
737
738 assert!(!state.get_bool(ConfigKey::MetricsEnabled).await);
739 }
740
741 #[tokio::test]
742 async fn test_push_relay_url_comes_from_the_file_unless_the_env_var_overrides_it() {
743 let temp_dir = TempDir::new().unwrap();
744 let mut config = PipedashConfig::default();
745 config.push.relay_url = "https://file.example.com".to_string();
746 let state = ConfigState::from_config(
747 config,
748 temp_dir.path().join("config.toml"),
749 Arc::new(MemoryTokenStore::new()),
750 );
751
752 {
753 let _env = EnvGuard::set(&[("PIPEDASH_PUSH_RELAY_URL", None)]);
754 assert_eq!(
755 state.get_string(ConfigKey::PushRelayUrl).await,
756 "https://file.example.com"
757 );
758 }
759
760 let _env = EnvGuard::set(&[("PIPEDASH_PUSH_RELAY_URL", Some("https://env.example.com"))]);
761 assert_eq!(
762 state.get_string(ConfigKey::PushRelayUrl).await,
763 "https://env.example.com"
764 );
765 }
766
767 fn update(default_refresh_interval: u32, status_pages_enabled: bool) -> GeneralSettingsUpdate {
768 GeneralSettingsUpdate {
769 metrics_enabled: true,
770 default_refresh_interval,
771 status_pages_enabled,
772 proxy_url: String::new(),
773 }
774 }
775
776 #[tokio::test]
777 async fn test_update_general_settings_saves_a_proxy_and_refuses_an_invalid_one() {
778 let temp_dir = TempDir::new().unwrap();
779 let state = create_test_state(&temp_dir, Arc::new(MemoryTokenStore::new()));
780 let path = temp_dir.path().join("config.toml");
781 let with_proxy = |proxy_url: &str| GeneralSettingsUpdate {
782 proxy_url: proxy_url.to_string(),
783 ..update(30, true)
784 };
785
786 state
787 .update_general_settings(with_proxy(" http://proxy.corp:3128 "))
788 .await
789 .unwrap();
790 let refused = state
791 .update_general_settings(with_proxy("http://alice:secret@proxy.corp:3128"))
792 .await;
793
794 assert!(matches!(refused, Err(DomainError::InvalidConfig(_))));
795 assert_eq!(
796 ConfigLoader::load(&path).unwrap().general.proxy_url,
797 "http://proxy.corp:3128"
798 );
799 assert_eq!(
800 state.get().await.general.proxy_url,
801 "http://proxy.corp:3128"
802 );
803
804 state.update_general_settings(with_proxy("")).await.unwrap();
805
806 assert_eq!(ConfigLoader::load(&path).unwrap().general.proxy_url, "");
807 }
808
809 #[tokio::test]
810 async fn test_update_general_settings_creates_the_general_table_and_survives_a_reload() {
811 let _env = EnvGuard::set(&[("PIPEDASH_STATUS_PAGES_ENABLED", None)]);
812 let temp_dir = TempDir::new().unwrap();
813 let state = create_test_state(&temp_dir, Arc::new(MemoryTokenStore::new()));
814 let path = temp_dir.path().join("config.toml");
815 std::fs::write(&path, "# keep me\n[storage]\nbackend = \"sqlite\"\n").unwrap();
816 let (health, mut receivers) = HealthHandle::channel();
817 state.attach_health(health);
818
819 state
820 .update_general_settings(update(60, false))
821 .await
822 .unwrap();
823 let saved = std::fs::read_to_string(&path).unwrap();
824 state.reload().await.unwrap();
825
826 assert!(saved.contains("# keep me"));
827 assert!(saved.contains("[general]"));
828 assert!(
829 !ConfigLoader::load(&path)
830 .unwrap()
831 .general
832 .status_pages_enabled
833 );
834 let config = state.get().await;
835 assert!(!config.general.status_pages_enabled);
836 assert_eq!(config.general.default_refresh_interval, 60);
837 assert!(!state.get_bool(ConfigKey::StatusPagesEnabled).await);
838 assert!(matches!(
839 receivers.inputs.try_recv(),
840 Ok(HealthInput::SettingsChanged)
841 ));
842 assert!(matches!(
843 receivers.inputs.try_recv(),
844 Ok(HealthInput::SettingsChanged)
845 ));
846 }
847
848 #[tokio::test]
849 async fn test_update_general_settings_rejects_intervals_outside_five_to_three_hundred() {
850 let temp_dir = TempDir::new().unwrap();
851 let state = create_test_state(&temp_dir, Arc::new(MemoryTokenStore::new()));
852 let (health, mut receivers) = HealthHandle::channel();
853 state.attach_health(health);
854
855 for interval in [0, 4, 301, 100_000] {
856 let result = state.update_general_settings(update(interval, false)).await;
857
858 assert!(
859 matches!(result, Err(DomainError::InvalidConfig(_))),
860 "{interval}"
861 );
862 }
863 assert!(state.get().await.general.status_pages_enabled);
864 assert!(!temp_dir.path().join("config.toml").exists());
865 assert!(receivers.inputs.try_recv().is_err());
866
867 for interval in [5, 300] {
868 state
869 .update_general_settings(update(interval, true))
870 .await
871 .unwrap();
872 assert_eq!(state.get().await.general.default_refresh_interval, interval);
873 assert!(matches!(
874 receivers.inputs.try_recv(),
875 Ok(HealthInput::SettingsChanged)
876 ));
877 }
878 }
879
880 #[tokio::test]
881 async fn test_update_general_settings_leaves_providers_and_secrets_alone() {
882 let _env = EnvGuard::set(&[("PIPEDASH_TEST_TOKEN", Some("secret-value"))]);
883 let temp_dir = TempDir::new().unwrap();
884 let path = temp_dir.path().join("config.toml");
885 let original = "[general]\nmetrics_enabled = true\n\n[providers.main]\ntype = \"github\"\ntoken = \"${PIPEDASH_TEST_TOKEN}\"\nrefresh_interval = 45\n";
886 std::fs::write(&path, original).unwrap();
887 let config = ConfigLoader::load(&path).unwrap();
888 assert_eq!(config.providers["main"].token, "secret-value");
889 let state =
890 ConfigState::from_config(config, path.clone(), Arc::new(MemoryTokenStore::new()));
891
892 state
893 .update_general_settings(update(90, false))
894 .await
895 .unwrap();
896
897 let saved = std::fs::read_to_string(&path).unwrap();
898 assert!(
899 saved.contains("token = \"${PIPEDASH_TEST_TOKEN}\""),
900 "{saved}"
901 );
902 assert!(!saved.contains("secret-value"), "{saved}");
903 assert!(saved.contains("refresh_interval = 45"));
904 assert!(saved.contains("status_pages_enabled = false"));
905 assert!(saved.contains("default_refresh_interval = 90"));
906 }
907
908 #[tokio::test]
909 async fn test_update_general_settings_keeps_the_proxy_reference() {
910 let _env = EnvGuard::set(&[("PIPEDASH_TEST_PROXY", Some("http://proxy.corp:3128"))]);
911 let temp_dir = TempDir::new().unwrap();
912 let path = temp_dir.path().join("config.toml");
913 std::fs::write(&path, "[general]\nproxy_url = \"${PIPEDASH_TEST_PROXY}\"\n").unwrap();
914 let config = ConfigLoader::load(&path).unwrap();
915 let state =
916 ConfigState::from_config(config, path.clone(), Arc::new(MemoryTokenStore::new()));
917 let general = state.get().await.general.clone();
918 let save = |proxy_url: &str| GeneralSettingsUpdate {
919 metrics_enabled: general.metrics_enabled,
920 default_refresh_interval: general.default_refresh_interval,
921 status_pages_enabled: !general.status_pages_enabled,
922 proxy_url: proxy_url.to_string(),
923 };
924
925 state
926 .update_general_settings(save(&general.proxy_url))
927 .await
928 .unwrap();
929 let kept = std::fs::read_to_string(&path).unwrap();
930 state
931 .update_general_settings(save("http://other.corp:8080"))
932 .await
933 .unwrap();
934 let replaced = std::fs::read_to_string(&path).unwrap();
935
936 assert!(
937 kept.contains("proxy_url = \"${PIPEDASH_TEST_PROXY}\""),
938 "{kept}"
939 );
940 assert!(
941 replaced.contains("proxy_url = \"http://other.corp:8080\""),
942 "{replaced}"
943 );
944 }
945
946 #[tokio::test]
947 async fn test_provider_persistence_creates_the_general_table_too() {
948 let temp_dir = TempDir::new().unwrap();
949 let path = temp_dir.path().join("config.toml");
950 std::fs::write(&path, "# keep me\n[storage]\nbackend = \"sqlite\"\n").unwrap();
951 let mut config = PipedashConfig::default();
952 config.general.status_pages_enabled = false;
953 let state =
954 ConfigState::from_config(config, path.clone(), Arc::new(MemoryTokenStore::new()));
955
956 state
957 .add_provider(
958 "main".to_string(),
959 ProviderFileConfig {
960 provider_type: "github".to_string(),
961 ..Default::default()
962 },
963 )
964 .await
965 .unwrap();
966
967 let saved = std::fs::read_to_string(&path).unwrap();
968 assert!(saved.contains("# keep me"));
969 assert!(saved.contains("[general]"));
970 let reloaded = ConfigLoader::load(&path).unwrap();
971 assert!(!reloaded.general.status_pages_enabled);
972 assert!(reloaded.providers.contains_key("main"));
973 }
974}