Skip to main content

pipedash_core/infrastructure/config/
state.rs

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![], // TODO: Calculate diff
212        };
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}