Skip to main content

dotstate/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5/// Repository setup mode
6/// Determines how the repository was configured and how sync operations authenticate
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
8pub enum RepoMode {
9    /// Repository created/managed via GitHub API (requires token for sync)
10    #[default]
11    GitHub,
12    /// User-provided repository (uses system git credentials for sync)
13    Local,
14}
15
16/// Update check configuration
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct UpdateConfig {
19    /// Whether to check for updates on startup (default: true)
20    #[serde(default = "default_update_check_enabled")]
21    pub check_enabled: bool,
22    /// Check interval in hours (default: 24)
23    #[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/// Main configuration structure
45/// Note: Profiles are stored in the repository manifest (.dotstate-profiles.toml), not in this config file.
46/// This config only stores local settings like backup preferences and active profile name.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Config {
49    /// Repository setup mode (GitHub API or local/user-provided)
50    #[serde(default)]
51    pub repo_mode: RepoMode,
52    /// GitHub repository information (only used in GitHub mode)
53    pub github: Option<GitHubConfig>,
54    /// Current active profile/set
55    pub active_profile: String,
56    /// Repository root path (where dotfiles are stored locally)
57    pub repo_path: PathBuf,
58    /// Repository name on GitHub (default: dotstate-storage)
59    #[serde(default = "default_repo_name")]
60    pub repo_name: String,
61    /// Default branch name (default: main)
62    #[serde(default = "default_branch_name")]
63    pub default_branch: String,
64    /// Whether to create backups before syncing (default: true)
65    #[serde(default = "default_backup_enabled")]
66    pub backup_enabled: bool,
67    /// Whether the active profile is currently activated (symlinks created)
68    #[serde(default)]
69    pub profile_activated: bool,
70    /// Custom file paths that the user has added (persists even if removed from sync)
71    #[serde(default)]
72    pub custom_files: Vec<String>,
73    /// Update check configuration
74    #[serde(default)]
75    pub updates: UpdateConfig,
76    /// Color theme: "dark", "light", or "nocolor" (default: dark)
77    #[serde(default = "default_theme")]
78    pub theme: String,
79    /// Icon set: "nerd", "unicode", or "ascii" (default: auto-detect)
80    #[serde(default = "default_icon_set")]
81    pub icon_set: String,
82    /// Keymap configuration (preset and overrides)
83    #[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    /// Repository owner (username or org)
98    pub owner: String,
99    /// Repository name
100    pub repo: String,
101    /// OAuth token or PAT
102    pub token: Option<String>,
103}
104
105// Profile struct removed - profiles are now stored in the repository manifest (.dotstate-profiles.toml)
106// Use crate::utils::ProfileManifest and ProfileInfo instead
107
108#[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    /// Load configuration from file or create default
147    /// If config doesn't exist, attempts to discover profiles from the repo manifest
148    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            // Set defaults for missing fields (for backward compatibility)
157            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            // backup_enabled defaults to true if not present
164            // (handled by serde default)
165
166            // If active_profile is empty and repo exists, try to set it from manifest
167            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            // Config doesn't exist - create default
182            tracing::info!(
183                "Config not found, creating default config at: {:?}",
184                config_path
185            );
186            let mut config = Self::default();
187
188            // Try to discover active profile from the repo manifest if repo_path exists
189            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    /// Save configuration to file with secure permissions
205    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        // Write file
214        std::fs::write(config_path, content)
215            .with_context(|| format!("Failed to write config file: {config_path:?}"))?;
216
217        // Set secure permissions (600: owner read/write only)
218        #[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    /// Check if the repository is configured (either GitHub or Local mode)
233    #[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    /// Reset config to unconfigured state
242    /// Used when setup fails partway through to ensure clean retry
243    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        // Keep repo_path as the default location - it will be recreated on next setup
249        // Keep other settings like backup_enabled, theme, keymap
250    }
251
252    /// Get GitHub token from environment variable or config
253    /// Priority: `DOTSTATE_GITHUB_TOKEN` env var > config token
254    /// Returns None if neither is set
255    pub fn get_github_token(&self) -> Option<String> {
256        // First, check environment variable
257        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        // Fall back to config token
267        self.github
268            .as_ref()
269            .and_then(|gh| gh.token.as_ref())
270            .cloned()
271    }
272
273    /// Get the icon set based on config value
274    /// Returns the configured icon set, or auto-detects if set to "auto"
275    #[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(), // Auto-detect or fallback to detection
285        }
286    }
287
288    // Profile-related methods removed - use ProfileManifest directly
289    // Helper method removed as it's not used - profiles are accessed via App::get_profiles() instead
290}
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        // Create a config with a non-existent repo_path to avoid manifest loading
311        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        // Both should have empty active_profile since repo_path doesn't exist
319        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        // Test GitHub mode
330        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        // Test Local mode
341        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        // Write an old-style config without repo_mode
359        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        // Load should default repo_mode to GitHub
374        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        // Write an old-style config without updates section
418        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        // Load should default updates to enabled with 24h interval
433        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        // Write config with custom update interval
445        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); // 1 week
466    }
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        // Write config with updates disabled
475        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        // Should still have default interval even when disabled
495        assert_eq!(loaded.updates.check_interval_hours, 24);
496    }
497}