ngdp_client/
config_manager.rs

1//! Configuration management for persistent storage of user settings
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7use thiserror::Error;
8
9#[derive(Error, Debug)]
10pub enum ConfigError {
11    #[error("IO error: {0}")]
12    Io(#[from] std::io::Error),
13    #[error("TOML serialization error: {0}")]
14    TomlSerialize(#[from] toml::ser::Error),
15    #[error("TOML deserialization error: {0}")]
16    TomlDeserialize(#[from] toml::de::Error),
17    #[error("Configuration key '{key}' not found")]
18    KeyNotFound { key: String },
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct NgdpConfig {
23    /// Built-in configuration values
24    #[serde(flatten)]
25    pub defaults: DefaultConfig,
26    /// User-defined custom configuration values
27    #[serde(default)]
28    pub custom: HashMap<String, String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DefaultConfig {
33    pub default_region: String,
34    pub cache_dir: String,
35    pub timeout: u32,
36    pub cache_enabled: bool,
37    pub cache_ttl: u32,
38    pub max_concurrent_downloads: u32,
39    pub user_agent: String,
40    pub verify_certificates: bool,
41    pub proxy_url: String,
42    pub ribbit_timeout: u32,
43    pub tact_timeout: u32,
44    pub retry_attempts: u32,
45    pub log_file: String,
46    pub color_output: bool,
47    pub fallback_to_tact: bool,
48    pub use_community_cdn_fallbacks: bool,
49    pub custom_cdn_fallbacks: String,
50}
51
52impl Default for DefaultConfig {
53    fn default() -> Self {
54        Self {
55            default_region: "us".to_string(),
56            cache_dir: "~/.cache/ngdp".to_string(),
57            timeout: 30,
58            cache_enabled: true,
59            cache_ttl: 1800, // 30 minutes in seconds
60            max_concurrent_downloads: 4,
61            user_agent: "ngdp-client/0.1.2".to_string(),
62            verify_certificates: true,
63            proxy_url: String::new(),
64            ribbit_timeout: 30,
65            tact_timeout: 30,
66            retry_attempts: 3,
67            log_file: String::new(),
68            color_output: true,
69            fallback_to_tact: true,
70            use_community_cdn_fallbacks: true,
71            custom_cdn_fallbacks: String::new(),
72        }
73    }
74}
75
76pub struct ConfigManager {
77    config_path: PathBuf,
78    config: NgdpConfig,
79}
80
81impl ConfigManager {
82    /// Create a new config manager and load existing configuration
83    pub fn new() -> Result<Self, ConfigError> {
84        let config_path = Self::get_config_path()?;
85        let config = Self::load_config(&config_path)?;
86
87        Ok(Self {
88            config_path,
89            config,
90        })
91    }
92
93    /// Get the configuration file path
94    fn get_config_path() -> Result<PathBuf, ConfigError> {
95        let config_dir = dirs::config_dir()
96            .unwrap_or_else(|| PathBuf::from("."))
97            .join("cascette");
98
99        // Ensure directory exists
100        if !config_dir.exists() {
101            fs::create_dir_all(&config_dir)?;
102        }
103
104        Ok(config_dir.join("ngdp-client.toml"))
105    }
106
107    /// Load configuration from file or create default
108    fn load_config(config_path: &PathBuf) -> Result<NgdpConfig, ConfigError> {
109        if config_path.exists() {
110            let content = fs::read_to_string(config_path)?;
111            let config: NgdpConfig = toml::from_str(&content)?;
112            Ok(config)
113        } else {
114            // Create default config
115            let config = NgdpConfig::default();
116            Self::save_config_to_file(config_path, &config)?;
117            Ok(config)
118        }
119    }
120
121    /// Save configuration to file
122    fn save_config_to_file(config_path: &PathBuf, config: &NgdpConfig) -> Result<(), ConfigError> {
123        let toml_content = toml::to_string_pretty(config)?;
124        fs::write(config_path, toml_content)?;
125        Ok(())
126    }
127
128    /// Save the current configuration to file
129    pub fn save(&self) -> Result<(), ConfigError> {
130        Self::save_config_to_file(&self.config_path, &self.config)
131    }
132
133    /// Get a configuration value by key
134    pub fn get(&self, key: &str) -> Result<String, ConfigError> {
135        // First check custom values
136        if let Some(value) = self.config.custom.get(key) {
137            return Ok(value.clone());
138        }
139
140        // Then check built-in defaults
141        let value = match key {
142            "default_region" => &self.config.defaults.default_region,
143            "cache_dir" => &self.config.defaults.cache_dir,
144            "timeout" => return Ok(self.config.defaults.timeout.to_string()),
145            "cache_enabled" => return Ok(self.config.defaults.cache_enabled.to_string()),
146            "cache_ttl" => return Ok(self.config.defaults.cache_ttl.to_string()),
147            "max_concurrent_downloads" => {
148                return Ok(self.config.defaults.max_concurrent_downloads.to_string());
149            }
150            "user_agent" => &self.config.defaults.user_agent,
151            "verify_certificates" => {
152                return Ok(self.config.defaults.verify_certificates.to_string());
153            }
154            "proxy_url" => &self.config.defaults.proxy_url,
155            "ribbit_timeout" => return Ok(self.config.defaults.ribbit_timeout.to_string()),
156            "tact_timeout" => return Ok(self.config.defaults.tact_timeout.to_string()),
157            "retry_attempts" => return Ok(self.config.defaults.retry_attempts.to_string()),
158            "log_file" => &self.config.defaults.log_file,
159            "color_output" => return Ok(self.config.defaults.color_output.to_string()),
160            "fallback_to_tact" => return Ok(self.config.defaults.fallback_to_tact.to_string()),
161            "use_community_cdn_fallbacks" => {
162                return Ok(self.config.defaults.use_community_cdn_fallbacks.to_string());
163            }
164            "custom_cdn_fallbacks" => &self.config.defaults.custom_cdn_fallbacks,
165            _ => {
166                return Err(ConfigError::KeyNotFound {
167                    key: key.to_string(),
168                });
169            }
170        };
171
172        Ok(value.clone())
173    }
174
175    /// Set a configuration value
176    pub fn set(&mut self, key: String, value: String) -> Result<(), ConfigError> {
177        // Always store custom values in the custom section
178        self.config.custom.insert(key, value);
179        self.save()?;
180        Ok(())
181    }
182
183    /// Get all configuration values as a HashMap
184    pub fn get_all(&self) -> HashMap<String, String> {
185        let mut all_config = HashMap::new();
186
187        // Add built-in defaults
188        all_config.insert(
189            "default_region".to_string(),
190            self.config.defaults.default_region.clone(),
191        );
192        all_config.insert(
193            "cache_dir".to_string(),
194            self.config.defaults.cache_dir.clone(),
195        );
196        all_config.insert(
197            "timeout".to_string(),
198            self.config.defaults.timeout.to_string(),
199        );
200        all_config.insert(
201            "cache_enabled".to_string(),
202            self.config.defaults.cache_enabled.to_string(),
203        );
204        all_config.insert(
205            "cache_ttl".to_string(),
206            self.config.defaults.cache_ttl.to_string(),
207        );
208        all_config.insert(
209            "max_concurrent_downloads".to_string(),
210            self.config.defaults.max_concurrent_downloads.to_string(),
211        );
212        all_config.insert(
213            "user_agent".to_string(),
214            self.config.defaults.user_agent.clone(),
215        );
216        all_config.insert(
217            "verify_certificates".to_string(),
218            self.config.defaults.verify_certificates.to_string(),
219        );
220        all_config.insert(
221            "proxy_url".to_string(),
222            self.config.defaults.proxy_url.clone(),
223        );
224        all_config.insert(
225            "ribbit_timeout".to_string(),
226            self.config.defaults.ribbit_timeout.to_string(),
227        );
228        all_config.insert(
229            "tact_timeout".to_string(),
230            self.config.defaults.tact_timeout.to_string(),
231        );
232        all_config.insert(
233            "retry_attempts".to_string(),
234            self.config.defaults.retry_attempts.to_string(),
235        );
236        all_config.insert(
237            "log_file".to_string(),
238            self.config.defaults.log_file.clone(),
239        );
240        all_config.insert(
241            "color_output".to_string(),
242            self.config.defaults.color_output.to_string(),
243        );
244        all_config.insert(
245            "fallback_to_tact".to_string(),
246            self.config.defaults.fallback_to_tact.to_string(),
247        );
248        all_config.insert(
249            "use_community_cdn_fallbacks".to_string(),
250            self.config.defaults.use_community_cdn_fallbacks.to_string(),
251        );
252        all_config.insert(
253            "custom_cdn_fallbacks".to_string(),
254            self.config.defaults.custom_cdn_fallbacks.clone(),
255        );
256
257        // Override with custom values
258        for (key, value) in &self.config.custom {
259            all_config.insert(key.clone(), value.clone());
260        }
261
262        all_config
263    }
264
265    /// Reset configuration to defaults
266    pub fn reset(&mut self) -> Result<(), ConfigError> {
267        self.config = NgdpConfig::default();
268        self.save()?;
269        Ok(())
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::env;
277    use tempfile::TempDir;
278
279    #[test]
280    fn test_config_creation_and_persistence() {
281        let temp_dir = TempDir::new().unwrap();
282        let config_path = temp_dir.path().join("test-config.toml");
283
284        // Create config with custom value
285        let mut config = NgdpConfig::default();
286        config
287            .custom
288            .insert("test.key".to_string(), "test_value".to_string());
289
290        // Save it
291        ConfigManager::save_config_to_file(&config_path, &config).unwrap();
292
293        // Load it back
294        let loaded_config = ConfigManager::load_config(&config_path).unwrap();
295
296        assert_eq!(loaded_config.custom.get("test.key").unwrap(), "test_value");
297        assert_eq!(loaded_config.defaults.default_region, "us");
298    }
299
300    #[test]
301    fn test_config_get_set() {
302        // Use a temporary directory for this test
303        let temp_dir = TempDir::new().unwrap();
304        // SAFETY: This is in a test context where we control the environment.
305        // Setting XDG_CONFIG_HOME is safe as tests run in isolation.
306        unsafe {
307            env::set_var("XDG_CONFIG_HOME", temp_dir.path());
308        }
309
310        let mut manager = ConfigManager::new().unwrap();
311
312        // Test setting and getting custom value
313        manager
314            .set("test.product".to_string(), "wow_classic_era".to_string())
315            .unwrap();
316        let value = manager.get("test.product").unwrap();
317        assert_eq!(value, "wow_classic_era");
318
319        // Test getting default value
320        let default_region = manager.get("default_region").unwrap();
321        assert_eq!(default_region, "us");
322
323        // Test non-existent key
324        let result = manager.get("nonexistent.key");
325        assert!(result.is_err());
326    }
327}