epik/config/
mod.rs

1use directories::ProjectDirs;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6use crate::{Error, Result};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Config {
10    pub install_dir: PathBuf,
11    pub log_level: String,
12    #[serde(default = "default_log_to_file")]
13    pub log_to_file: bool,
14    #[serde(default = "default_crash_reporting")]
15    pub enable_crash_reporting: bool,
16    #[serde(default = "default_privacy_mode")]
17    pub privacy_mode: bool,
18
19    // General settings
20    #[serde(default = "default_auto_update")]
21    pub auto_update: bool,
22    #[serde(default = "default_minimize_to_tray")]
23    pub minimize_to_tray: bool,
24    #[serde(default = "default_close_to_tray")]
25    pub close_to_tray: bool,
26    #[serde(default = "default_language")]
27    pub language: String,
28    #[serde(default = "default_theme")]
29    pub theme: String,
30    // Download settings
31    #[serde(default = "default_max_concurrent_downloads")]
32    pub max_concurrent_downloads: u32,
33    #[serde(default = "default_download_threads")]
34    pub download_threads: u32,
35    #[serde(default)]
36    pub enable_bandwidth_limit: bool,
37    #[serde(default = "default_bandwidth_limit")]
38    pub bandwidth_limit_mbps: u32,
39    #[serde(default = "default_cdn_region")]
40    pub cdn_region: String,
41
42    // Wine settings
43    #[serde(default = "default_wine_prefix")]
44    pub wine_prefix: String,
45    #[serde(default)]
46    pub enable_dxvk: bool,
47    #[serde(default)]
48    pub enable_esync: bool,
49    #[serde(default)]
50    pub enable_fsync: bool,
51
52    // Advanced settings
53    #[serde(default)]
54    pub disable_telemetry: bool,
55    #[serde(default)]
56    pub clear_cache_on_exit: bool,
57    #[serde(default = "default_telemetry_opt_in")]
58    pub telemetry_opt_in: bool,
59}
60
61fn default_auto_update() -> bool {
62    false
63}
64
65fn default_minimize_to_tray() -> bool {
66    false
67}
68
69fn default_close_to_tray() -> bool {
70    false
71}
72fn default_max_concurrent_downloads() -> u32 {
73    3
74}
75
76fn default_download_threads() -> u32 {
77    4
78}
79
80fn default_bandwidth_limit() -> u32 {
81    10
82}
83
84fn default_cdn_region() -> String {
85    "Auto".to_string()
86}
87
88fn default_wine_prefix() -> String {
89    "~/.wine".to_string()
90}
91
92fn default_language() -> String {
93    "en".to_string()
94}
95
96fn default_theme() -> String {
97    "Epic".to_string()
98}
99
100fn default_log_to_file() -> bool {
101    true
102}
103
104fn default_crash_reporting() -> bool {
105    true
106}
107
108fn default_privacy_mode() -> bool {
109    false
110}
111
112fn default_telemetry_opt_in() -> bool {
113    true
114}
115
116impl Default for Config {
117    fn default() -> Self {
118        let project_dirs =
119            ProjectDirs::from("", "", "epik").expect("Failed to determine project directories");
120
121        Self {
122            install_dir: project_dirs.data_dir().join("games"),
123            log_level: "info".to_string(),
124            log_to_file: default_log_to_file(),
125            enable_crash_reporting: default_crash_reporting(),
126            privacy_mode: default_privacy_mode(),
127            auto_update: default_auto_update(),
128            minimize_to_tray: default_minimize_to_tray(),
129            close_to_tray: default_close_to_tray(),
130            language: default_language(),
131            theme: default_theme(),
132            max_concurrent_downloads: default_max_concurrent_downloads(),
133            download_threads: default_download_threads(),
134            enable_bandwidth_limit: false,
135            bandwidth_limit_mbps: default_bandwidth_limit(),
136            cdn_region: default_cdn_region(),
137            wine_prefix: default_wine_prefix(),
138            enable_dxvk: false,
139            enable_esync: false,
140            enable_fsync: false,
141            disable_telemetry: false,
142            clear_cache_on_exit: false,
143            telemetry_opt_in: default_telemetry_opt_in(),
144        }
145    }
146}
147
148impl Config {
149    pub fn load() -> Result<Self> {
150        // TODO: Handle config migration for version changes
151        // TODO: Merge user config with defaults for missing values
152
153        let config_path = Self::config_path()?;
154
155        if config_path.exists() {
156            let contents = fs::read_to_string(&config_path)?;
157            let config: Config = toml::from_str(&contents)?;
158            config.validate()?;
159            Ok(config)
160        } else {
161            let config = Self::default();
162            config.save()?;
163            Ok(config)
164        }
165    }
166
167    // Reloads configuration from disk, updating current values if successful.
168    pub fn reload(&mut self) -> Result<()> {
169        let new_config = Self::load()?;
170        *self = new_config;
171        Ok(())
172    }
173
174    // Validate configuration values
175    fn validate(&self) -> Result<()> {
176        // Validate log level
177        let valid_log_levels = ["trace", "debug", "info", "warn", "error"];
178        let normalized_level = self.log_level.to_lowercase();
179        if !valid_log_levels.contains(&normalized_level.as_str()) {
180            return Err(Error::Config(format!(
181                "Invalid log level: '{}'. Must be one of: {}",
182                self.log_level,
183                valid_log_levels.join(", ")
184            )));
185        }
186
187        // Validate install directory - ensure parent exists or can be created
188        if let Some(parent) = self.install_dir.parent() {
189            if !parent.exists() {
190                return Err(Error::Config(format!(
191                    "Install directory parent does not exist: {}",
192                    parent.display()
193                )));
194            }
195        }
196
197        // Privacy mode and telemetry coherence
198        if self.disable_telemetry && self.telemetry_opt_in {
199            return Err(Error::Config(
200                "Telemetry settings inconsistent: disable_telemetry=true but telemetry_opt_in=true"
201                    .to_string(),
202            ));
203        }
204
205        Ok(())
206    }
207
208    pub fn save(&self) -> Result<()> {
209        let config_path = Self::config_path()?;
210
211        if let Some(parent) = config_path.parent() {
212            fs::create_dir_all(parent)?;
213        }
214
215        let contents = toml::to_string_pretty(self).map_err(|e| Error::Config(e.to_string()))?;
216        fs::write(&config_path, contents)?;
217
218        Ok(())
219    }
220
221    pub fn config_path() -> Result<PathBuf> {
222        let project_dirs = ProjectDirs::from("", "", "epik")
223            .ok_or_else(|| Error::Config("Failed to determine project directories".to_string()))?;
224
225        Ok(project_dirs.config_dir().join("config.toml"))
226    }
227
228    pub fn data_dir() -> Result<PathBuf> {
229        let project_dirs = ProjectDirs::from("", "", "epik")
230            .ok_or_else(|| Error::Config("Failed to determine project directories".to_string()))?;
231
232        Ok(project_dirs.data_dir().to_path_buf())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_config_default() {
242        let config = Config::default();
243        assert_eq!(config.log_level, "info");
244        assert!(config.install_dir.to_string_lossy().contains("games"));
245    }
246
247    #[test]
248    fn test_config_serialization() {
249        let config = Config::default();
250        let serialized = toml::to_string(&config).unwrap();
251        let deserialized: Config = toml::from_str(&serialized).unwrap();
252        assert_eq!(config.log_level, deserialized.log_level);
253    }
254}