use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const CURRENT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum RepoMode {
#[default]
GitHub,
Local,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateConfig {
#[serde(default = "default_update_check_enabled")]
pub check_enabled: bool,
#[serde(default = "default_update_check_interval")]
pub check_interval_hours: u64,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
check_enabled: default_update_check_enabled(),
check_interval_hours: default_update_check_interval(),
}
}
}
fn default_update_check_enabled() -> bool {
true
}
fn default_update_check_interval() -> u64 {
24
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub repo_mode: RepoMode,
pub github: Option<GitHubConfig>,
pub active_profile: String,
pub repo_path: PathBuf,
#[serde(default = "default_repo_name")]
pub repo_name: String,
#[serde(default = "default_branch_name")]
pub default_branch: String,
#[serde(default = "default_backup_enabled")]
pub backup_enabled: bool,
#[serde(default)]
pub profile_activated: bool,
#[serde(default)]
pub custom_files: Vec<String>,
#[serde(default)]
pub updates: UpdateConfig,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_icon_set")]
pub icon_set: String,
#[serde(default)]
pub keymap: crate::keymap::Keymap,
#[serde(default = "default_embed_credentials")]
pub embed_credentials_in_url: bool,
}
fn default_embed_credentials() -> bool {
true
}
fn default_theme() -> String {
"dark".to_string()
}
fn default_icon_set() -> String {
"auto".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubConfig {
pub owner: String,
pub repo: String,
pub token: Option<String>,
}
#[must_use]
pub fn default_repo_name() -> String {
"dotstate-storage".to_string()
}
fn default_branch_name() -> String {
"main".to_string()
}
fn default_backup_enabled() -> bool {
true
}
impl Default for Config {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
repo_mode: RepoMode::default(),
github: None,
active_profile: String::new(),
backup_enabled: true,
profile_activated: true,
repo_path: dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("dotstate")
.join("storage"),
repo_name: default_repo_name(),
default_branch: "main".to_string(),
custom_files: Vec::new(),
updates: UpdateConfig::default(),
theme: default_theme(),
icon_set: default_icon_set(),
keymap: crate::keymap::Keymap::default(),
embed_credentials_in_url: default_embed_credentials(),
}
}
}
impl Config {
pub fn load_or_create(config_path: &Path) -> Result<Self> {
if config_path.exists() {
tracing::debug!("Loading config from: {:?}", config_path);
let content = std::fs::read_to_string(config_path)
.with_context(|| format!("Failed to read config file: {config_path:?}"))?;
let mut config: Config =
toml::from_str(&content).with_context(|| "Failed to parse config file")?;
if config.version < CURRENT_VERSION {
let old_version = config.version;
tracing::info!(
"Migrating config from v{} to v{}",
old_version,
CURRENT_VERSION
);
config = Self::migrate(config)?;
crate::utils::migrate_file(config_path, old_version, "toml", || {
config.save(config_path)
})?;
}
if config.repo_name.is_empty() {
config.repo_name = default_repo_name();
}
if config.default_branch.is_empty() {
config.default_branch = default_branch_name();
}
if config.active_profile.is_empty() && config.repo_path.exists() {
if let Ok(manifest) =
crate::utils::ProfileManifest::load_or_backfill(&config.repo_path)
{
if let Some(first_profile) = manifest.profiles.first() {
config.active_profile = first_profile.name.clone();
config.save(config_path)?;
}
}
}
tracing::info!("Config loaded successfully");
Ok(config)
} else {
tracing::info!(
"Config not found, creating default config at: {:?}",
config_path
);
let mut config = Self::default();
if config.repo_path.exists() {
if let Ok(manifest) =
crate::utils::ProfileManifest::load_or_backfill(&config.repo_path)
{
if let Some(first_profile) = manifest.profiles.first() {
config.active_profile = first_profile.name.clone();
}
}
}
config.save(config_path)?;
Ok(config)
}
}
pub fn save(&self, config_path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self).with_context(|| "Failed to serialize config")?;
let temp_path = config_path.with_extension("toml.tmp");
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {parent:?}"))?;
}
std::fs::write(&temp_path, &content)
.with_context(|| format!("Failed to write temp config: {temp_path:?}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&temp_path)
.with_context(|| format!("Failed to get file metadata: {temp_path:?}"))?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&temp_path, perms)
.with_context(|| format!("Failed to set file permissions: {temp_path:?}"))?;
}
std::fs::rename(&temp_path, config_path)
.with_context(|| format!("Failed to rename temp config to {config_path:?}"))?;
Ok(())
}
#[must_use]
pub fn is_repo_configured(&self) -> bool {
match self.repo_mode {
RepoMode::GitHub => self.github.is_some(),
RepoMode::Local => self.repo_path.join(".git").exists(),
}
}
pub fn reset_to_unconfigured(&mut self) {
self.github = None;
self.active_profile = String::new();
self.profile_activated = false;
self.repo_name = default_repo_name();
}
pub fn get_github_token(&self) -> Option<String> {
if let Ok(token) = std::env::var("DOTSTATE_GITHUB_TOKEN") {
if !token.is_empty() {
tracing::debug!(
"Using GitHub token from DOTSTATE_GITHUB_TOKEN environment variable"
);
return Some(token);
}
}
self.github
.as_ref()
.and_then(|gh| gh.token.as_ref())
.cloned()
}
#[must_use]
pub fn get_icon_set(&self) -> crate::icons::IconSet {
use crate::icons::IconSet;
match self.icon_set.to_lowercase().as_str() {
"nerd" | "nerdfont" | "nerdfonts" => IconSet::NerdFonts,
"unicode" => IconSet::Unicode,
"emoji" => IconSet::Emoji,
"ascii" | "plain" => IconSet::Ascii,
_ => IconSet::detect(), }
}
fn migrate(mut config: Self) -> Result<Self> {
if config.version == 0 {
config = Self::migrate_v0_to_v1(config)?;
}
Ok(config)
}
fn migrate_v0_to_v1(mut config: Self) -> Result<Self> {
tracing::debug!("Migrating config v0 -> v1");
config.version = 1;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.active_profile, "");
assert_eq!(config.repo_mode, RepoMode::GitHub);
}
#[test]
fn test_config_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let config = Config {
repo_path: repo_path.clone(),
..Default::default()
};
config.save(&config_path).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert_eq!(config.active_profile, loaded.active_profile);
assert_eq!(loaded.active_profile, "");
}
#[test]
fn test_repo_mode_serialization() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let config = Config {
repo_path: repo_path.clone(),
repo_mode: RepoMode::GitHub,
..Default::default()
};
config.save(&config_path).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert_eq!(loaded.repo_mode, RepoMode::GitHub);
let config = Config {
repo_path: repo_path.clone(),
repo_mode: RepoMode::Local,
..Default::default()
};
config.save(&config_path).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert_eq!(loaded.repo_mode, RepoMode::Local);
}
#[test]
fn test_old_config_defaults_to_github_mode() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let old_config = format!(
r#"
active_profile = ""
repo_path = "{}"
repo_name = "dotstate-storage"
default_branch = "main"
backup_enabled = true
profile_activated = true
custom_files = []
"#,
repo_path.display()
);
std::fs::write(&config_path, old_config).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert_eq!(loaded.repo_mode, RepoMode::GitHub);
}
#[test]
fn test_update_config_defaults() {
let update_config = UpdateConfig::default();
assert!(update_config.check_enabled);
assert_eq!(update_config.check_interval_hours, 24);
}
#[test]
fn test_config_includes_update_config() {
let config = Config::default();
assert!(config.updates.check_enabled);
assert_eq!(config.updates.check_interval_hours, 24);
}
#[test]
fn test_update_config_serialization() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let mut config = Config {
repo_path,
..Default::default()
};
config.updates.check_enabled = false;
config.updates.check_interval_hours = 48;
config.save(&config_path).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert!(!loaded.updates.check_enabled);
assert_eq!(loaded.updates.check_interval_hours, 48);
}
#[test]
fn test_old_config_defaults_update_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let old_config = format!(
r#"
active_profile = ""
repo_path = "{}"
repo_name = "dotstate-storage"
default_branch = "main"
backup_enabled = true
profile_activated = true
custom_files = []
"#,
repo_path.display()
);
std::fs::write(&config_path, old_config).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert!(loaded.updates.check_enabled);
assert_eq!(loaded.updates.check_interval_hours, 24);
}
#[test]
fn test_update_config_custom_interval() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let config_content = format!(
r#"
active_profile = ""
repo_path = "{}"
repo_name = "dotstate-storage"
default_branch = "main"
backup_enabled = true
profile_activated = true
custom_files = []
[updates]
check_enabled = true
check_interval_hours = 168
"#,
repo_path.display()
);
std::fs::write(&config_path, config_content).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert!(loaded.updates.check_enabled);
assert_eq!(loaded.updates.check_interval_hours, 168); }
#[test]
fn test_update_config_disabled() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let config_content = format!(
r#"
active_profile = ""
repo_path = "{}"
repo_name = "dotstate-storage"
default_branch = "main"
backup_enabled = true
profile_activated = true
custom_files = []
[updates]
check_enabled = false
"#,
repo_path.display()
);
std::fs::write(&config_path, config_content).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert!(!loaded.updates.check_enabled);
assert_eq!(loaded.updates.check_interval_hours, 24);
}
#[test]
fn test_config_migration_v0_to_v1() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let v0_config = format!(
r#"
active_profile = "test"
repo_path = "{}"
repo_name = "dotstate-storage"
default_branch = "main"
backup_enabled = true
profile_activated = true
custom_files = []
"#,
repo_path.display()
);
std::fs::write(&config_path, v0_config).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert_eq!(loaded.version, 1);
assert_eq!(loaded.active_profile, "test");
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(content.contains("version = 1"));
let backup_path = config_path.with_extension("toml.backup-v0");
assert!(!backup_path.exists());
}
#[test]
fn test_config_already_at_current_version() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("repo");
let v1_config = format!(
r#"
version = 1
active_profile = "test"
repo_path = "{}"
repo_name = "dotstate-storage"
default_branch = "main"
backup_enabled = true
profile_activated = true
custom_files = []
"#,
repo_path.display()
);
std::fs::write(&config_path, v1_config).unwrap();
let loaded = Config::load_or_create(&config_path).unwrap();
assert_eq!(loaded.version, 1);
let backup_path = config_path.with_extension("toml.backup-v0");
assert!(!backup_path.exists());
}
#[test]
fn test_new_config_has_current_version() {
let config = Config::default();
assert_eq!(config.version, 1);
}
}