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