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