use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub last_identity: Option<String>,
pub auto_login_enabled: bool,
pub keyring_enabled: bool,
pub biometric_enabled: bool,
pub recent_identities: Vec<RecentIdentity>,
pub ui_preferences: UiPreferences,
pub version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentIdentity {
pub four_words: String,
pub display_name: String,
pub last_used: u64,
pub has_passkey: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiPreferences {
pub theme: Theme,
pub language: String,
pub show_identity_picker_on_startup: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Theme {
Light,
Dark,
System,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
last_identity: None,
auto_login_enabled: true,
keyring_enabled: true,
biometric_enabled: true,
recent_identities: Vec::new(),
ui_preferences: UiPreferences::default(),
version: 1,
}
}
}
impl Default for UiPreferences {
fn default() -> Self {
Self {
theme: Theme::System,
language: "en".to_string(),
show_identity_picker_on_startup: true,
}
}
}
pub struct AppConfigManager {
config_path: PathBuf,
config: AppConfig,
}
impl AppConfigManager {
pub async fn new(config_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&config_dir)
.await
.context("Failed to create config directory")?;
let config_path = config_dir.join("app_config.json");
let config = if config_path.exists() {
let data = fs::read_to_string(&config_path)
.await
.context("Failed to read config file")?;
serde_json::from_str(&data).context("Failed to parse config file")?
} else {
AppConfig::default()
};
Ok(Self {
config_path,
config,
})
}
pub fn get_config(&self) -> &AppConfig {
&self.config
}
pub fn get_config_mut(&mut self) -> &mut AppConfig {
&mut self.config
}
pub async fn set_last_identity(
&mut self,
four_words: String,
display_name: String,
) -> Result<()> {
self.config.last_identity = Some(four_words.clone());
let now = current_timestamp();
if let Some(existing) = self
.config
.recent_identities
.iter_mut()
.find(|r| r.four_words == four_words)
{
existing.last_used = now;
existing.display_name = display_name;
} else {
self.config.recent_identities.push(RecentIdentity {
four_words,
display_name,
last_used: now,
has_passkey: false,
});
}
self.config
.recent_identities
.sort_by(|a, b| b.last_used.cmp(&a.last_used));
self.config.recent_identities.truncate(10);
self.save().await
}
pub async fn set_identity_has_passkey(
&mut self,
four_words: &str,
has_passkey: bool,
) -> Result<()> {
if let Some(identity) = self
.config
.recent_identities
.iter_mut()
.find(|r| r.four_words == four_words)
{
identity.has_passkey = has_passkey;
self.save().await?;
}
Ok(())
}
pub async fn set_auto_login(&mut self, enabled: bool) -> Result<()> {
self.config.auto_login_enabled = enabled;
self.save().await
}
pub async fn set_keyring_enabled(&mut self, enabled: bool) -> Result<()> {
self.config.keyring_enabled = enabled;
self.save().await
}
pub async fn set_biometric_enabled(&mut self, enabled: bool) -> Result<()> {
self.config.biometric_enabled = enabled;
self.save().await
}
pub async fn set_ui_preferences(&mut self, preferences: UiPreferences) -> Result<()> {
self.config.ui_preferences = preferences;
self.save().await
}
pub async fn clear_last_identity(&mut self) -> Result<()> {
self.config.last_identity = None;
self.save().await
}
pub async fn remove_recent_identity(&mut self, four_words: &str) -> Result<()> {
self.config
.recent_identities
.retain(|r| r.four_words != four_words);
if self.config.last_identity.as_deref() == Some(four_words) {
self.config.last_identity = None;
}
self.save().await
}
async fn save(&self) -> Result<()> {
let json =
serde_json::to_string_pretty(&self.config).context("Failed to serialize config")?;
fs::write(&self.config_path, json)
.await
.context("Failed to write config file")?;
Ok(())
}
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_config_creation() {
let temp_dir = TempDir::new().unwrap();
let manager = AppConfigManager::new(temp_dir.path().to_path_buf())
.await
.unwrap();
assert!(manager.get_config().last_identity.is_none());
assert!(manager.get_config().auto_login_enabled);
assert!(manager.get_config().keyring_enabled);
}
#[tokio::test]
async fn test_set_last_identity() {
let temp_dir = TempDir::new().unwrap();
let mut manager = AppConfigManager::new(temp_dir.path().to_path_buf())
.await
.unwrap();
manager
.set_last_identity("ocean-forest-moon-star".to_string(), "Alice".to_string())
.await
.unwrap();
assert_eq!(
manager.get_config().last_identity,
Some("ocean-forest-moon-star".to_string())
);
assert_eq!(manager.get_config().recent_identities.len(), 1);
assert_eq!(
manager.get_config().recent_identities[0].four_words,
"ocean-forest-moon-star"
);
}
#[tokio::test]
async fn test_recent_identities_limit() {
let temp_dir = TempDir::new().unwrap();
let mut manager = AppConfigManager::new(temp_dir.path().to_path_buf())
.await
.unwrap();
for i in 0..15 {
manager
.set_last_identity(format!("identity-{}", i), format!("User {}", i))
.await
.unwrap();
}
assert_eq!(manager.get_config().recent_identities.len(), 10);
}
#[tokio::test]
async fn test_persistence() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().to_path_buf();
{
let mut manager = AppConfigManager::new(config_dir.clone()).await.unwrap();
manager
.set_last_identity("test-identity".to_string(), "Test User".to_string())
.await
.unwrap();
manager.set_auto_login(false).await.unwrap();
}
let manager = AppConfigManager::new(config_dir).await.unwrap();
assert_eq!(
manager.get_config().last_identity,
Some("test-identity".to_string())
);
assert!(!manager.get_config().auto_login_enabled);
}
#[tokio::test]
async fn test_passkey_tracking() {
let temp_dir = TempDir::new().unwrap();
let mut manager = AppConfigManager::new(temp_dir.path().to_path_buf())
.await
.unwrap();
manager
.set_last_identity("test-identity".to_string(), "Test User".to_string())
.await
.unwrap();
assert!(!manager.get_config().recent_identities[0].has_passkey);
manager
.set_identity_has_passkey("test-identity", true)
.await
.unwrap();
assert!(manager.get_config().recent_identities[0].has_passkey);
}
#[tokio::test]
async fn test_remove_recent_identity() {
let temp_dir = TempDir::new().unwrap();
let mut manager = AppConfigManager::new(temp_dir.path().to_path_buf())
.await
.unwrap();
manager
.set_last_identity("test-identity".to_string(), "Test User".to_string())
.await
.unwrap();
assert_eq!(manager.get_config().recent_identities.len(), 1);
manager
.remove_recent_identity("test-identity")
.await
.unwrap();
assert_eq!(manager.get_config().recent_identities.len(), 0);
assert!(manager.get_config().last_identity.is_none());
}
}