1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
8pub enum RepoMode {
9 #[default]
11 GitHub,
12 Local,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct UpdateConfig {
19 #[serde(default = "default_update_check_enabled")]
21 pub check_enabled: bool,
22 #[serde(default = "default_update_check_interval")]
24 pub check_interval_hours: u64,
25}
26
27impl Default for UpdateConfig {
28 fn default() -> Self {
29 Self {
30 check_enabled: default_update_check_enabled(),
31 check_interval_hours: default_update_check_interval(),
32 }
33 }
34}
35
36fn default_update_check_enabled() -> bool {
37 true
38}
39
40fn default_update_check_interval() -> u64 {
41 24
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Config {
49 #[serde(default)]
51 pub repo_mode: RepoMode,
52 pub github: Option<GitHubConfig>,
54 pub active_profile: String,
56 pub repo_path: PathBuf,
58 #[serde(default = "default_repo_name")]
60 pub repo_name: String,
61 #[serde(default = "default_branch_name")]
63 pub default_branch: String,
64 #[serde(default = "default_backup_enabled")]
66 pub backup_enabled: bool,
67 #[serde(default)]
69 pub profile_activated: bool,
70 #[serde(default)]
72 pub custom_files: Vec<String>,
73 #[serde(default)]
75 pub updates: UpdateConfig,
76 #[serde(default = "default_theme")]
78 pub theme: String,
79 #[serde(default = "default_icon_set")]
81 pub icon_set: String,
82 #[serde(default)]
84 pub keymap: crate::keymap::Keymap,
85}
86
87fn default_theme() -> String {
88 "dark".to_string()
89}
90
91fn default_icon_set() -> String {
92 "auto".to_string()
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct GitHubConfig {
97 pub owner: String,
99 pub repo: String,
101 pub token: Option<String>,
103}
104
105#[must_use]
109pub fn default_repo_name() -> String {
110 "dotstate-storage".to_string()
111}
112
113fn default_branch_name() -> String {
114 "main".to_string()
115}
116
117fn default_backup_enabled() -> bool {
118 true
119}
120
121impl Default for Config {
122 fn default() -> Self {
123 Self {
124 repo_mode: RepoMode::default(),
125 github: None,
126 active_profile: String::new(),
127 backup_enabled: true,
128 profile_activated: true,
129 repo_path: dirs::home_dir()
130 .unwrap_or_else(|| PathBuf::from("."))
131 .join(".config")
132 .join("dotstate")
133 .join("storage"),
134 repo_name: default_repo_name(),
135 default_branch: "main".to_string(),
136 custom_files: Vec::new(),
137 updates: UpdateConfig::default(),
138 theme: default_theme(),
139 icon_set: default_icon_set(),
140 keymap: crate::keymap::Keymap::default(),
141 }
142 }
143}
144
145impl Config {
146 pub fn load_or_create(config_path: &Path) -> Result<Self> {
149 if config_path.exists() {
150 tracing::debug!("Loading config from: {:?}", config_path);
151 let content = std::fs::read_to_string(config_path)
152 .with_context(|| format!("Failed to read config file: {config_path:?}"))?;
153 let mut config: Config =
154 toml::from_str(&content).with_context(|| "Failed to parse config file")?;
155
156 if config.repo_name.is_empty() {
158 config.repo_name = default_repo_name();
159 }
160 if config.default_branch.is_empty() {
161 config.default_branch = default_branch_name();
162 }
163 if config.active_profile.is_empty() && config.repo_path.exists() {
168 if let Ok(manifest) =
169 crate::utils::ProfileManifest::load_or_backfill(&config.repo_path)
170 {
171 if let Some(first_profile) = manifest.profiles.first() {
172 config.active_profile = first_profile.name.clone();
173 config.save(config_path)?;
174 }
175 }
176 }
177
178 tracing::info!("Config loaded successfully");
179 Ok(config)
180 } else {
181 tracing::info!(
183 "Config not found, creating default config at: {:?}",
184 config_path
185 );
186 let mut config = Self::default();
187
188 if config.repo_path.exists() {
190 if let Ok(manifest) =
191 crate::utils::ProfileManifest::load_or_backfill(&config.repo_path)
192 {
193 if let Some(first_profile) = manifest.profiles.first() {
194 config.active_profile = first_profile.name.clone();
195 }
196 }
197 }
198
199 config.save(config_path)?;
200 Ok(config)
201 }
202 }
203
204 pub fn save(&self, config_path: &Path) -> Result<()> {
206 let content = toml::to_string_pretty(self).with_context(|| "Failed to serialize config")?;
207
208 if let Some(parent) = config_path.parent() {
209 std::fs::create_dir_all(parent)
210 .with_context(|| format!("Failed to create config directory: {parent:?}"))?;
211 }
212
213 std::fs::write(config_path, content)
215 .with_context(|| format!("Failed to write config file: {config_path:?}"))?;
216
217 #[cfg(unix)]
219 {
220 use std::os::unix::fs::PermissionsExt;
221 let mut perms = std::fs::metadata(config_path)
222 .with_context(|| format!("Failed to get file metadata: {config_path:?}"))?
223 .permissions();
224 perms.set_mode(0o600);
225 std::fs::set_permissions(config_path, perms)
226 .with_context(|| format!("Failed to set file permissions: {config_path:?}"))?;
227 }
228
229 Ok(())
230 }
231
232 #[must_use]
234 pub fn is_repo_configured(&self) -> bool {
235 match self.repo_mode {
236 RepoMode::GitHub => self.github.is_some(),
237 RepoMode::Local => self.repo_path.join(".git").exists(),
238 }
239 }
240
241 pub fn reset_to_unconfigured(&mut self) {
244 self.github = None;
245 self.active_profile = String::new();
246 self.profile_activated = false;
247 self.repo_name = default_repo_name();
248 }
251
252 pub fn get_github_token(&self) -> Option<String> {
256 if let Ok(token) = std::env::var("DOTSTATE_GITHUB_TOKEN") {
258 if !token.is_empty() {
259 tracing::debug!(
260 "Using GitHub token from DOTSTATE_GITHUB_TOKEN environment variable"
261 );
262 return Some(token);
263 }
264 }
265
266 self.github
268 .as_ref()
269 .and_then(|gh| gh.token.as_ref())
270 .cloned()
271 }
272
273 #[must_use]
276 pub fn get_icon_set(&self) -> crate::icons::IconSet {
277 use crate::icons::IconSet;
278
279 match self.icon_set.to_lowercase().as_str() {
280 "nerd" | "nerdfont" | "nerdfonts" => IconSet::NerdFonts,
281 "unicode" => IconSet::Unicode,
282 "emoji" => IconSet::Emoji,
283 "ascii" | "plain" => IconSet::Ascii,
284 _ => IconSet::detect(), }
286 }
287
288 }
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use tempfile::TempDir;
296
297 #[test]
298 fn test_config_default() {
299 let config = Config::default();
300 assert_eq!(config.active_profile, "");
301 assert_eq!(config.repo_mode, RepoMode::GitHub);
302 }
303
304 #[test]
305 fn test_config_save_and_load() {
306 let temp_dir = TempDir::new().unwrap();
307 let config_path = temp_dir.path().join("config.toml");
308 let repo_path = temp_dir.path().join("repo");
309
310 let config = Config {
312 repo_path: repo_path.clone(),
313 ..Default::default()
314 };
315 config.save(&config_path).unwrap();
316
317 let loaded = Config::load_or_create(&config_path).unwrap();
318 assert_eq!(config.active_profile, loaded.active_profile);
320 assert_eq!(loaded.active_profile, "");
321 }
322
323 #[test]
324 fn test_repo_mode_serialization() {
325 let temp_dir = TempDir::new().unwrap();
326 let config_path = temp_dir.path().join("config.toml");
327 let repo_path = temp_dir.path().join("repo");
328
329 let config = Config {
331 repo_path: repo_path.clone(),
332 repo_mode: RepoMode::GitHub,
333 ..Default::default()
334 };
335 config.save(&config_path).unwrap();
336
337 let loaded = Config::load_or_create(&config_path).unwrap();
338 assert_eq!(loaded.repo_mode, RepoMode::GitHub);
339
340 let config = Config {
342 repo_path: repo_path.clone(),
343 repo_mode: RepoMode::Local,
344 ..Default::default()
345 };
346 config.save(&config_path).unwrap();
347
348 let loaded = Config::load_or_create(&config_path).unwrap();
349 assert_eq!(loaded.repo_mode, RepoMode::Local);
350 }
351
352 #[test]
353 fn test_old_config_defaults_to_github_mode() {
354 let temp_dir = TempDir::new().unwrap();
355 let config_path = temp_dir.path().join("config.toml");
356 let repo_path = temp_dir.path().join("repo");
357
358 let old_config = format!(
360 r#"
361active_profile = ""
362repo_path = "{}"
363repo_name = "dotstate-storage"
364default_branch = "main"
365backup_enabled = true
366profile_activated = true
367custom_files = []
368"#,
369 repo_path.display()
370 );
371 std::fs::write(&config_path, old_config).unwrap();
372
373 let loaded = Config::load_or_create(&config_path).unwrap();
375 assert_eq!(loaded.repo_mode, RepoMode::GitHub);
376 }
377
378 #[test]
379 fn test_update_config_defaults() {
380 let update_config = UpdateConfig::default();
381 assert!(update_config.check_enabled);
382 assert_eq!(update_config.check_interval_hours, 24);
383 }
384
385 #[test]
386 fn test_config_includes_update_config() {
387 let config = Config::default();
388 assert!(config.updates.check_enabled);
389 assert_eq!(config.updates.check_interval_hours, 24);
390 }
391
392 #[test]
393 fn test_update_config_serialization() {
394 let temp_dir = TempDir::new().unwrap();
395 let config_path = temp_dir.path().join("config.toml");
396 let repo_path = temp_dir.path().join("repo");
397
398 let mut config = Config {
399 repo_path,
400 ..Default::default()
401 };
402 config.updates.check_enabled = false;
403 config.updates.check_interval_hours = 48;
404 config.save(&config_path).unwrap();
405
406 let loaded = Config::load_or_create(&config_path).unwrap();
407 assert!(!loaded.updates.check_enabled);
408 assert_eq!(loaded.updates.check_interval_hours, 48);
409 }
410
411 #[test]
412 fn test_old_config_defaults_update_config() {
413 let temp_dir = TempDir::new().unwrap();
414 let config_path = temp_dir.path().join("config.toml");
415 let repo_path = temp_dir.path().join("repo");
416
417 let old_config = format!(
419 r#"
420active_profile = ""
421repo_path = "{}"
422repo_name = "dotstate-storage"
423default_branch = "main"
424backup_enabled = true
425profile_activated = true
426custom_files = []
427"#,
428 repo_path.display()
429 );
430 std::fs::write(&config_path, old_config).unwrap();
431
432 let loaded = Config::load_or_create(&config_path).unwrap();
434 assert!(loaded.updates.check_enabled);
435 assert_eq!(loaded.updates.check_interval_hours, 24);
436 }
437
438 #[test]
439 fn test_update_config_custom_interval() {
440 let temp_dir = TempDir::new().unwrap();
441 let config_path = temp_dir.path().join("config.toml");
442 let repo_path = temp_dir.path().join("repo");
443
444 let config_content = format!(
446 r#"
447active_profile = ""
448repo_path = "{}"
449repo_name = "dotstate-storage"
450default_branch = "main"
451backup_enabled = true
452profile_activated = true
453custom_files = []
454
455[updates]
456check_enabled = true
457check_interval_hours = 168
458"#,
459 repo_path.display()
460 );
461 std::fs::write(&config_path, config_content).unwrap();
462
463 let loaded = Config::load_or_create(&config_path).unwrap();
464 assert!(loaded.updates.check_enabled);
465 assert_eq!(loaded.updates.check_interval_hours, 168); }
467
468 #[test]
469 fn test_update_config_disabled() {
470 let temp_dir = TempDir::new().unwrap();
471 let config_path = temp_dir.path().join("config.toml");
472 let repo_path = temp_dir.path().join("repo");
473
474 let config_content = format!(
476 r#"
477active_profile = ""
478repo_path = "{}"
479repo_name = "dotstate-storage"
480default_branch = "main"
481backup_enabled = true
482profile_activated = true
483custom_files = []
484
485[updates]
486check_enabled = false
487"#,
488 repo_path.display()
489 );
490 std::fs::write(&config_path, config_content).unwrap();
491
492 let loaded = Config::load_or_create(&config_path).unwrap();
493 assert!(!loaded.updates.check_enabled);
494 assert_eq!(loaded.updates.check_interval_hours, 24);
496 }
497}