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 set_push_relay_url(&self, relay_url: &str) -> DomainResult<()> {
359 let current_config = self.config.read().await;
360 let mut config = (**current_config).clone();
361 drop(current_config);
362
363 config.push.relay_url = relay_url.trim().trim_end_matches('/').to_string();
364
365 self.persist_edit(&config, |doc| {
366 if config.push.relay_url.is_empty() {
367 doc.remove("push");
368 } else {
369 doc.entry("push").or_insert_with(toml_edit::table)["relay_url"] =
370 toml_edit::value(config.push.relay_url.as_str());
371 }
372 })
373 .await?;
374
375 let _ = self.change_tx.send(ConfigChangeEvent::ValueChanged {
376 key: "push".to_string(),
377 old_value: None,
378 new_value: "updated".to_string(),
379 });
380
381 Ok(())
382 }
383
384 pub fn push_relay_from_env() -> bool {
385 std::env::var(ConfigKey::PushRelayUrl.env_var_name())
386 .is_ok_and(|value| !value.trim().is_empty())
387 }
388
389 pub async fn sync_providers_from_toml(
390 &self, config_backend: &dyn ConfigBackend, delete_orphans: bool,
391 ) -> DomainResult<SyncResult> {
392 let config = self.config.read().await;
393 self.sync_service
394 .sync_toml_to_db(
395 config.providers.clone(),
396 config_backend,
397 self.token_store.as_ref(),
398 delete_orphans,
399 )
400 .await
401 }
402
403 pub fn load_file(&self) -> DomainResult<PipedashConfig> {
404 let file = ConfigLoader::load(&self.config_path)
405 .map_err(|e| DomainError::InvalidConfig(format!("Failed to reload config: {}", e)))?;
406
407 let validation = file.validate();
408 if !validation.errors.is_empty() {
409 return Err(DomainError::InvalidConfig(format!(
410 "Config validation failed: {:?}",
411 validation.errors
412 )));
413 }
414
415 Ok(file)
416 }
417
418 pub fn load_providers(&self) -> DomainResult<indexmap::IndexMap<String, ProviderFileConfig>> {
419 self.load_file().map(|file| file.providers)
420 }
421
422 pub async fn remember_provider(&self, id: &str, provider: &ProviderFileConfig) {
423 let mut config = self.config.write().await;
424 let mut next = (**config).clone();
425 next.providers.insert(id.to_string(), provider.clone());
426 *config = Arc::new(next);
427 }
428
429 pub async fn remember_providers(
430 &self, providers: indexmap::IndexMap<String, ProviderFileConfig>,
431 ) {
432 let mut config = self.config.write().await;
433 let mut next = (**config).clone();
434 next.providers = providers;
435 *config = Arc::new(next);
436 }
437
438 pub async fn remember_views(
439 &self, views: indexmap::IndexMap<String, super::schema::ViewFileConfig>,
440 ) {
441 let mut config = self.config.write().await;
442 let mut next = (**config).clone();
443 next.views = views;
444 *config = Arc::new(next);
445 }
446
447 async fn persist_config(&self, config: &PipedashConfig) -> DomainResult<()> {
448 let existing_content = std::fs::read_to_string(&self.config_path).ok();
449
450 let new_content = if let Some(existing) = existing_content {
451 Self::update_toml_preserving_comments(&existing, config)?
452 } else {
453 ConfigLoader::to_toml(config).map_err(|e| {
454 DomainError::DatabaseError(format!("Failed to serialize config: {}", e))
455 })?
456 };
457
458 std::fs::write(&self.config_path, &new_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!("Config persisted to {:?}", self.config_path);
465 Ok(())
466 }
467
468 async fn persist_general(&self, config: &PipedashConfig) -> DomainResult<()> {
469 self.persist_edit(config, |doc| {
470 ConfigLoader::apply_general(doc, &config.general)
471 })
472 .await
473 }
474
475 async fn persist_edit(
476 &self, config: &PipedashConfig, edit: impl FnOnce(&mut toml_edit::DocumentMut),
477 ) -> DomainResult<()> {
478 let content = match std::fs::read_to_string(&self.config_path) {
479 Ok(existing) => {
480 let mut doc: toml_edit::DocumentMut = existing.parse().map_err(|e| {
481 DomainError::DatabaseError(format!("Failed to parse existing TOML: {}", e))
482 })?;
483 edit(&mut doc);
484 doc.to_string()
485 }
486 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ConfigLoader::to_toml(config)
487 .map_err(|e| {
488 DomainError::DatabaseError(format!("Failed to serialize config: {}", e))
489 })?,
490 Err(e) => {
491 return Err(DomainError::DatabaseError(format!(
492 "Failed to read config file: {}",
493 e
494 )))
495 }
496 };
497
498 std::fs::write(&self.config_path, content).map_err(|e| {
499 DomainError::DatabaseError(format!("Failed to write config file: {}", e))
500 })?;
501
502 *self.config.write().await = Arc::new(config.clone());
503
504 tracing::debug!("Config settings persisted to {:?}", self.config_path);
505 Ok(())
506 }
507
508 pub(super) fn update_toml_preserving_comments(
509 existing: &str, config: &PipedashConfig,
510 ) -> DomainResult<String> {
511 use toml_edit::{
512 value,
513 DocumentMut,
514 };
515
516 let mut doc: DocumentMut = existing.parse().map_err(|e| {
517 DomainError::DatabaseError(format!("Failed to parse existing TOML: {}", e))
518 })?;
519
520 ConfigLoader::apply_general(&mut doc, &config.general);
521
522 if let Some(server) = doc.get_mut("server") {
523 if let Some(table) = server.as_table_mut() {
524 table["bind_addr"] = value(&config.server.bind_addr);
525 table["cors_allow_all"] = value(config.server.cors_allow_all);
526 }
527 }
528
529 Self::write_providers(&mut doc, &config.providers);
530
531 Ok(doc.to_string())
532 }
533
534 pub(super) fn write_providers(
535 doc: &mut toml_edit::DocumentMut,
536 providers: &indexmap::IndexMap<String, ProviderFileConfig>,
537 ) {
538 use toml_edit::{
539 value,
540 Item,
541 };
542
543 doc.remove("providers");
544
545 if providers.is_empty() {
546 return;
547 }
548
549 let mut providers_table = toml_edit::Table::new();
550
551 for (id, provider) in providers {
552 let mut provider_table = toml_edit::Table::new();
553
554 if let Some(name) = &provider.name {
555 provider_table["name"] = value(name);
556 }
557
558 provider_table["type"] = value(&provider.provider_type);
559 provider_table["token"] = value(&provider.token);
560 provider_table["refresh_interval"] = value(provider.refresh_interval as i64);
561
562 if !provider.config.is_empty() {
563 let mut config_table = toml_edit::Table::new();
564 let mut entries: Vec<_> = provider.config.iter().collect();
565 entries.sort();
566 for (k, v) in entries {
567 config_table[k] = value(v);
568 }
569 provider_table["config"] = Item::Table(config_table);
570 }
571
572 providers_table[id] = Item::Table(provider_table);
573 }
574
575 doc["providers"] = Item::Table(providers_table);
576 }
577
578 pub async fn get_string(&self, key: ConfigKey) -> String {
579 if let Ok(val) = std::env::var(key.env_var_name()) {
580 return val;
581 }
582
583 let config = self.config.read().await;
584 match key {
585 ConfigKey::MetricsEnabled => config.general.metrics_enabled.to_string(),
586 ConfigKey::DefaultRefreshInterval => {
587 config.general.default_refresh_interval.to_string()
588 }
589 ConfigKey::StatusPagesEnabled => config.general.status_pages_enabled.to_string(),
590 ConfigKey::BindAddr => config.server.bind_addr.clone(),
591 ConfigKey::CorsAllowAll => config.server.cors_allow_all.to_string(),
592 ConfigKey::DataDir => {
593 if config.storage.data_dir.is_empty() {
594 PipedashConfig::default_data_dir()
595 .to_string_lossy()
596 .to_string()
597 } else {
598 config.storage.data_dir.clone()
599 }
600 }
601 ConfigKey::StorageBackend => config.storage.backend.to_string(),
602 ConfigKey::PostgresConnectionString => {
603 config.storage.postgres.connection_string.clone()
604 }
605 ConfigKey::PushRelayUrl => config.push.relay_url.clone(),
606 }
607 }
608
609 pub async fn get_bool(&self, key: ConfigKey) -> bool {
610 let val = self.get_string(key).await;
611 matches!(val.to_lowercase().as_str(), "true" | "1" | "yes")
612 }
613
614 pub async fn get_u32(&self, key: ConfigKey) -> u32 {
615 self.get_string(key).await.parse().unwrap_or(0)
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use tempfile::TempDir;
622
623 use super::*;
624 use crate::{
625 infrastructure::MemoryTokenStore,
626 test_env::EnvGuard,
627 };
628
629 fn create_test_state(temp_dir: &TempDir, token_store: Arc<dyn TokenStore>) -> Arc<ConfigState> {
630 let config_path = temp_dir.path().join("config.toml");
631 let config = PipedashConfig::default();
632 ConfigState::from_config(config, config_path, token_store)
633 }
634
635 #[tokio::test]
636 async fn test_config_state_from_config() {
637 let temp_dir = TempDir::new().unwrap();
638 let token_store = Arc::new(MemoryTokenStore::new());
639
640 let state = create_test_state(&temp_dir, token_store);
641
642 let config = state.get().await;
643 assert!(config.general.metrics_enabled);
644 }
645
646 #[tokio::test]
647 async fn test_add_remove_provider() {
648 let temp_dir = TempDir::new().unwrap();
649 let token_store = Arc::new(MemoryTokenStore::new());
650
651 let state = create_test_state(&temp_dir, token_store);
652
653 let provider = ProviderFileConfig {
654 name: Some("Test GitHub".to_string()),
655 provider_type: "github".to_string(),
656 token: "${GITHUB_TOKEN:-}".to_string(),
657 refresh_interval: 30,
658 config: std::collections::HashMap::new(),
659 };
660
661 state
662 .add_provider("test-github".to_string(), provider)
663 .await
664 .unwrap();
665
666 let config = state.get().await;
667 assert_eq!(config.providers.len(), 1);
668 assert!(config.providers.contains_key("test-github"));
669
670 state.remove_provider("test-github").await.unwrap();
671
672 let config = state.get().await;
673 assert!(config.providers.is_empty());
674 }
675
676 #[tokio::test]
677 async fn test_reload_replaces_in_memory_config() {
678 let temp_dir = TempDir::new().unwrap();
679 let token_store = Arc::new(MemoryTokenStore::new());
680
681 let state = create_test_state(&temp_dir, token_store);
682
683 let mut on_disk = PipedashConfig::default();
684 on_disk.general.default_refresh_interval = 123;
685 std::fs::write(
686 temp_dir.path().join("config.toml"),
687 ConfigLoader::to_toml(&on_disk).unwrap(),
688 )
689 .unwrap();
690
691 state.reload().await.unwrap();
692
693 assert_eq!(state.get().await.general.default_refresh_interval, 123);
694 }
695
696 #[tokio::test]
697 async fn test_subscribe_to_changes() {
698 let temp_dir = TempDir::new().unwrap();
699 let token_store = Arc::new(MemoryTokenStore::new());
700
701 let state = create_test_state(&temp_dir, token_store);
702
703 let mut rx = state.subscribe();
704
705 let provider = ProviderFileConfig {
706 name: None,
707 provider_type: "github".to_string(),
708 token: String::new(),
709 refresh_interval: 30,
710 config: std::collections::HashMap::new(),
711 };
712
713 state
714 .add_provider("test".to_string(), provider)
715 .await
716 .unwrap();
717
718 let event = rx.try_recv().unwrap();
719 assert!(matches!(event, ConfigChangeEvent::ProviderAdded { .. }));
720 }
721
722 #[tokio::test]
723 async fn test_persist_and_reload() {
724 let temp_dir = TempDir::new().unwrap();
725 let token_store = Arc::new(MemoryTokenStore::new());
726 let config_path = temp_dir.path().join("config.toml");
727
728 let state = ConfigState::from_config(
729 PipedashConfig::default(),
730 config_path.clone(),
731 token_store.clone(),
732 );
733
734 let provider = ProviderFileConfig {
735 name: Some("Persist Test".to_string()),
736 provider_type: "gitlab".to_string(),
737 token: "${GITLAB_TOKEN:-}".to_string(),
738 refresh_interval: 60,
739 config: std::collections::HashMap::new(),
740 };
741
742 state
743 .add_provider("persist-test".to_string(), provider)
744 .await
745 .unwrap();
746
747 let loaded_config = ConfigLoader::load(&config_path).unwrap();
748 assert_eq!(loaded_config.providers.len(), 1);
749 assert!(loaded_config.providers.contains_key("persist-test"));
750 assert_eq!(loaded_config.providers["persist-test"].refresh_interval, 60);
751 }
752
753 #[tokio::test]
754 async fn test_get_config_values() {
755 let _env = EnvGuard::set(&[
756 ("PIPEDASH_METRICS_ENABLED", None),
757 ("PIPEDASH_DEFAULT_REFRESH_INTERVAL", None),
758 ]);
759
760 let temp_dir = TempDir::new().unwrap();
761 let token_store = Arc::new(MemoryTokenStore::new());
762
763 let state = create_test_state(&temp_dir, token_store);
764
765 assert!(state.get_bool(ConfigKey::MetricsEnabled).await);
766 assert_eq!(state.get_u32(ConfigKey::DefaultRefreshInterval).await, 30);
767 }
768
769 #[tokio::test]
770 async fn test_env_var_override() {
771 let _env = EnvGuard::set(&[("PIPEDASH_METRICS_ENABLED", Some("false"))]);
772
773 let temp_dir = TempDir::new().unwrap();
774 let token_store = Arc::new(MemoryTokenStore::new());
775
776 let state = create_test_state(&temp_dir, token_store);
777
778 assert!(!state.get_bool(ConfigKey::MetricsEnabled).await);
779 }
780
781 #[tokio::test]
782 async fn test_push_relay_url_comes_from_the_file_unless_the_env_var_overrides_it() {
783 let temp_dir = TempDir::new().unwrap();
784 let mut config = PipedashConfig::default();
785 config.push.relay_url = "https://file.example.com".to_string();
786 let state = ConfigState::from_config(
787 config,
788 temp_dir.path().join("config.toml"),
789 Arc::new(MemoryTokenStore::new()),
790 );
791
792 {
793 let _env = EnvGuard::set(&[("PIPEDASH_PUSH_RELAY_URL", None)]);
794 assert_eq!(
795 state.get_string(ConfigKey::PushRelayUrl).await,
796 "https://file.example.com"
797 );
798 }
799
800 let _env = EnvGuard::set(&[("PIPEDASH_PUSH_RELAY_URL", Some("https://env.example.com"))]);
801 assert_eq!(
802 state.get_string(ConfigKey::PushRelayUrl).await,
803 "https://env.example.com"
804 );
805 }
806
807 #[tokio::test]
808 async fn test_set_push_relay_url_edits_only_the_push_table() {
809 let temp_dir = TempDir::new().unwrap();
810 let path = temp_dir.path().join("config.toml");
811 std::fs::write(&path, "# keep me\n[general]\nmetrics_enabled = false\n").unwrap();
812 let state = ConfigState::from_config(
813 PipedashConfig::default(),
814 path.clone(),
815 Arc::new(MemoryTokenStore::new()),
816 );
817
818 state
819 .set_push_relay_url(" https://relay.pipedash.app/ ")
820 .await
821 .unwrap();
822 let written = std::fs::read_to_string(&path).unwrap();
823 assert!(written.contains("# keep me"));
824 assert!(written.contains("[push]\nrelay_url = \"https://relay.pipedash.app\""));
825 assert_eq!(
826 state.get().await.push.relay_url,
827 "https://relay.pipedash.app"
828 );
829
830 state.set_push_relay_url("").await.unwrap();
831 let cleared = std::fs::read_to_string(&path).unwrap();
832 assert!(!cleared.contains("push"));
833 assert!(cleared.contains("# keep me"));
834 assert!(state.get().await.push.relay_url.is_empty());
835 }
836
837 #[test]
838 fn test_push_relay_from_env_ignores_blank_values() {
839 for (value, expected) in [
840 (None, false),
841 (Some(" "), false),
842 (Some("https://r"), true),
843 ] {
844 let _env = EnvGuard::set(&[("PIPEDASH_PUSH_RELAY_URL", value)]);
845 assert_eq!(ConfigState::push_relay_from_env(), expected);
846 }
847 }
848
849 fn update(default_refresh_interval: u32, status_pages_enabled: bool) -> GeneralSettingsUpdate {
850 GeneralSettingsUpdate {
851 metrics_enabled: true,
852 default_refresh_interval,
853 status_pages_enabled,
854 proxy_url: String::new(),
855 }
856 }
857
858 #[tokio::test]
859 async fn test_update_general_settings_saves_a_proxy_and_refuses_an_invalid_one() {
860 let temp_dir = TempDir::new().unwrap();
861 let state = create_test_state(&temp_dir, Arc::new(MemoryTokenStore::new()));
862 let path = temp_dir.path().join("config.toml");
863 let with_proxy = |proxy_url: &str| GeneralSettingsUpdate {
864 proxy_url: proxy_url.to_string(),
865 ..update(30, true)
866 };
867
868 state
869 .update_general_settings(with_proxy(" http://proxy.corp:3128 "))
870 .await
871 .unwrap();
872 let refused = state
873 .update_general_settings(with_proxy("http://alice:secret@proxy.corp:3128"))
874 .await;
875
876 assert!(matches!(refused, Err(DomainError::InvalidConfig(_))));
877 assert_eq!(
878 ConfigLoader::load(&path).unwrap().general.proxy_url,
879 "http://proxy.corp:3128"
880 );
881 assert_eq!(
882 state.get().await.general.proxy_url,
883 "http://proxy.corp:3128"
884 );
885
886 state.update_general_settings(with_proxy("")).await.unwrap();
887
888 assert_eq!(ConfigLoader::load(&path).unwrap().general.proxy_url, "");
889 }
890
891 #[tokio::test]
892 async fn test_update_general_settings_creates_the_general_table_and_survives_a_reload() {
893 let _env = EnvGuard::set(&[("PIPEDASH_STATUS_PAGES_ENABLED", None)]);
894 let temp_dir = TempDir::new().unwrap();
895 let state = create_test_state(&temp_dir, Arc::new(MemoryTokenStore::new()));
896 let path = temp_dir.path().join("config.toml");
897 std::fs::write(&path, "# keep me\n[storage]\nbackend = \"sqlite\"\n").unwrap();
898 let (health, mut receivers) = HealthHandle::channel();
899 state.attach_health(health);
900
901 state
902 .update_general_settings(update(60, false))
903 .await
904 .unwrap();
905 let saved = std::fs::read_to_string(&path).unwrap();
906 state.reload().await.unwrap();
907
908 assert!(saved.contains("# keep me"));
909 assert!(saved.contains("[general]"));
910 assert!(
911 !ConfigLoader::load(&path)
912 .unwrap()
913 .general
914 .status_pages_enabled
915 );
916 let config = state.get().await;
917 assert!(!config.general.status_pages_enabled);
918 assert_eq!(config.general.default_refresh_interval, 60);
919 assert!(!state.get_bool(ConfigKey::StatusPagesEnabled).await);
920 assert!(matches!(
921 receivers.inputs.try_recv(),
922 Ok(HealthInput::SettingsChanged)
923 ));
924 assert!(matches!(
925 receivers.inputs.try_recv(),
926 Ok(HealthInput::SettingsChanged)
927 ));
928 }
929
930 #[tokio::test]
931 async fn test_update_general_settings_rejects_intervals_outside_five_to_three_hundred() {
932 let temp_dir = TempDir::new().unwrap();
933 let state = create_test_state(&temp_dir, Arc::new(MemoryTokenStore::new()));
934 let (health, mut receivers) = HealthHandle::channel();
935 state.attach_health(health);
936
937 for interval in [0, 4, 301, 100_000] {
938 let result = state.update_general_settings(update(interval, false)).await;
939
940 assert!(
941 matches!(result, Err(DomainError::InvalidConfig(_))),
942 "{interval}"
943 );
944 }
945 assert!(state.get().await.general.status_pages_enabled);
946 assert!(!temp_dir.path().join("config.toml").exists());
947 assert!(receivers.inputs.try_recv().is_err());
948
949 for interval in [5, 300] {
950 state
951 .update_general_settings(update(interval, true))
952 .await
953 .unwrap();
954 assert_eq!(state.get().await.general.default_refresh_interval, interval);
955 assert!(matches!(
956 receivers.inputs.try_recv(),
957 Ok(HealthInput::SettingsChanged)
958 ));
959 }
960 }
961
962 #[tokio::test]
963 async fn test_update_general_settings_leaves_providers_and_secrets_alone() {
964 let _env = EnvGuard::set(&[("PIPEDASH_TEST_TOKEN", Some("secret-value"))]);
965 let temp_dir = TempDir::new().unwrap();
966 let path = temp_dir.path().join("config.toml");
967 let original = "[general]\nmetrics_enabled = true\n\n[providers.main]\ntype = \"github\"\ntoken = \"${PIPEDASH_TEST_TOKEN}\"\nrefresh_interval = 45\n";
968 std::fs::write(&path, original).unwrap();
969 let config = ConfigLoader::load(&path).unwrap();
970 assert_eq!(config.providers["main"].token, "secret-value");
971 let state =
972 ConfigState::from_config(config, path.clone(), Arc::new(MemoryTokenStore::new()));
973
974 state
975 .update_general_settings(update(90, false))
976 .await
977 .unwrap();
978
979 let saved = std::fs::read_to_string(&path).unwrap();
980 assert!(
981 saved.contains("token = \"${PIPEDASH_TEST_TOKEN}\""),
982 "{saved}"
983 );
984 assert!(!saved.contains("secret-value"), "{saved}");
985 assert!(saved.contains("refresh_interval = 45"));
986 assert!(saved.contains("status_pages_enabled = false"));
987 assert!(saved.contains("default_refresh_interval = 90"));
988 }
989
990 #[tokio::test]
991 async fn test_update_general_settings_keeps_the_proxy_reference() {
992 let _env = EnvGuard::set(&[("PIPEDASH_TEST_PROXY", Some("http://proxy.corp:3128"))]);
993 let temp_dir = TempDir::new().unwrap();
994 let path = temp_dir.path().join("config.toml");
995 std::fs::write(&path, "[general]\nproxy_url = \"${PIPEDASH_TEST_PROXY}\"\n").unwrap();
996 let config = ConfigLoader::load(&path).unwrap();
997 let state =
998 ConfigState::from_config(config, path.clone(), Arc::new(MemoryTokenStore::new()));
999 let general = state.get().await.general.clone();
1000 let save = |proxy_url: &str| GeneralSettingsUpdate {
1001 metrics_enabled: general.metrics_enabled,
1002 default_refresh_interval: general.default_refresh_interval,
1003 status_pages_enabled: !general.status_pages_enabled,
1004 proxy_url: proxy_url.to_string(),
1005 };
1006
1007 state
1008 .update_general_settings(save(&general.proxy_url))
1009 .await
1010 .unwrap();
1011 let kept = std::fs::read_to_string(&path).unwrap();
1012 state
1013 .update_general_settings(save("http://other.corp:8080"))
1014 .await
1015 .unwrap();
1016 let replaced = std::fs::read_to_string(&path).unwrap();
1017
1018 assert!(
1019 kept.contains("proxy_url = \"${PIPEDASH_TEST_PROXY}\""),
1020 "{kept}"
1021 );
1022 assert!(
1023 replaced.contains("proxy_url = \"http://other.corp:8080\""),
1024 "{replaced}"
1025 );
1026 }
1027
1028 #[tokio::test]
1029 async fn test_provider_persistence_creates_the_general_table_too() {
1030 let temp_dir = TempDir::new().unwrap();
1031 let path = temp_dir.path().join("config.toml");
1032 std::fs::write(&path, "# keep me\n[storage]\nbackend = \"sqlite\"\n").unwrap();
1033 let mut config = PipedashConfig::default();
1034 config.general.status_pages_enabled = false;
1035 let state =
1036 ConfigState::from_config(config, path.clone(), Arc::new(MemoryTokenStore::new()));
1037
1038 state
1039 .add_provider(
1040 "main".to_string(),
1041 ProviderFileConfig {
1042 provider_type: "github".to_string(),
1043 ..Default::default()
1044 },
1045 )
1046 .await
1047 .unwrap();
1048
1049 let saved = std::fs::read_to_string(&path).unwrap();
1050 assert!(saved.contains("# keep me"));
1051 assert!(saved.contains("[general]"));
1052 let reloaded = ConfigLoader::load(&path).unwrap();
1053 assert!(!reloaded.general.status_pages_enabled);
1054 assert!(reloaded.providers.contains_key("main"));
1055 }
1056}