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 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}