use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
const CONFIG_DIR: &str = "wme";
const CONFIG_FILE: &str = "config.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub username: Option<String>,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub token_expires_at: Option<String>,
pub refresh_token_expires_at: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self> {
let config_path = match path {
Some(p) => p.to_path_buf(),
None => default_config_path()?,
};
if config_path.exists() {
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {}", config_path.display()))?;
let config: Config = serde_json::from_str(&content).with_context(|| {
format!("Failed to parse config from {}", config_path.display())
})?;
Ok(config)
} else {
Ok(Config::default())
}
}
pub fn save(&self, path: Option<&Path>) -> Result<()> {
let config_path = match path {
Some(p) => p.to_path_buf(),
None => default_config_path()?,
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory {}", parent.display())
})?;
}
let content = serde_json::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&config_path, content)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
Ok(())
}
pub fn default_path() -> Result<PathBuf> {
default_config_path()
}
pub fn is_token_expired(&self) -> bool {
match &self.token_expires_at {
Some(expires_at) => {
match chrono::DateTime::parse_from_rfc3339(expires_at) {
Ok(expiry) => chrono::Utc::now() > expiry.with_timezone(&chrono::Utc),
Err(_) => true, }
}
None => true, }
}
pub fn get_access_token(&self) -> Option<String> {
if self.is_token_expired() {
None
} else {
self.access_token.clone()
}
}
pub fn set_tokens(
&mut self,
username: &str,
access_token: &str,
refresh_token: &str,
expires_in: u64,
) {
self.username = Some(username.to_string());
self.access_token = Some(access_token.to_string());
self.refresh_token = Some(refresh_token.to_string());
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
self.token_expires_at = Some(expires_at.to_rfc3339());
let refresh_expires_at = chrono::Utc::now() + chrono::Duration::days(90);
self.refresh_token_expires_at = Some(refresh_expires_at.to_rfc3339());
}
pub fn clear_tokens(&mut self) {
self.username = None;
self.access_token = None;
self.refresh_token = None;
self.token_expires_at = None;
self.refresh_token_expires_at = None;
}
}
fn default_config_path() -> Result<PathBuf> {
let dirs = directories::BaseDirs::new().context("Failed to get home directory")?;
Ok(dirs
.home_dir()
.join(format!(".{}", CONFIG_DIR))
.join(CONFIG_FILE))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_config_default() {
let config = Config::default();
assert!(config.username.is_none());
assert!(config.access_token.is_none());
assert!(config.refresh_token.is_none());
assert!(config.token_expires_at.is_none());
assert!(config.extra.is_empty());
}
#[test]
fn test_config_load_nonexistent() {
let result = Config::load(Some(Path::new("/nonexistent/path/config.json")));
assert!(result.is_ok());
let config = result.unwrap();
assert!(config.username.is_none());
}
#[test]
fn test_config_load_and_save() {
let temp_file = NamedTempFile::new().unwrap();
let config = Config {
username: Some("testuser".to_string()),
access_token: Some("test_token".to_string()),
refresh_token: Some("refresh_token".to_string()),
token_expires_at: Some("2099-12-31T23:59:59Z".to_string()),
refresh_token_expires_at: Some("2099-12-31T23:59:59Z".to_string()),
extra: std::collections::HashMap::default(),
};
config.save(Some(temp_file.path())).unwrap();
let loaded = Config::load(Some(temp_file.path())).unwrap();
assert_eq!(loaded.username, Some("testuser".to_string()));
assert_eq!(loaded.access_token, Some("test_token".to_string()));
assert_eq!(loaded.refresh_token, Some("refresh_token".to_string()));
assert_eq!(
loaded.token_expires_at,
Some("2099-12-31T23:59:59Z".to_string())
);
}
#[test]
fn test_is_token_expired_no_expiry() {
let config = Config::default();
assert!(config.is_token_expired());
}
#[test]
fn test_is_token_expired_past() {
let config = Config {
token_expires_at: Some("2000-01-01T00:00:00Z".to_string()),
..Default::default()
};
assert!(config.is_token_expired());
}
#[test]
fn test_is_token_expired_future() {
let config = Config {
token_expires_at: Some("2099-12-31T23:59:59Z".to_string()),
..Default::default()
};
assert!(!config.is_token_expired());
}
#[test]
fn test_is_token_expired_invalid_format() {
let config = Config {
token_expires_at: Some("not-a-date".to_string()),
..Default::default()
};
assert!(config.is_token_expired());
}
#[test]
fn test_get_access_token_expired() {
let config = Config {
access_token: Some("token".to_string()),
token_expires_at: Some("2000-01-01T00:00:00Z".to_string()),
..Default::default()
};
assert_eq!(config.get_access_token(), None);
}
#[test]
fn test_get_access_token_valid() {
let config = Config {
access_token: Some("valid_token".to_string()),
token_expires_at: Some("2099-12-31T23:59:59Z".to_string()),
..Default::default()
};
assert_eq!(config.get_access_token(), Some("valid_token".to_string()));
}
#[test]
fn test_set_tokens() {
let mut config = Config::default();
config.set_tokens("myuser", "access123", "refresh456", 3600);
assert_eq!(config.username, Some("myuser".to_string()));
assert_eq!(config.access_token, Some("access123".to_string()));
assert_eq!(config.refresh_token, Some("refresh456".to_string()));
assert!(config.token_expires_at.is_some());
assert!(!config.is_token_expired());
}
#[test]
fn test_clear_tokens() {
let mut config = Config::default();
config.set_tokens("user", "access", "refresh", 3600);
config.clear_tokens();
assert!(config.username.is_none());
assert!(config.access_token.is_none());
assert!(config.refresh_token.is_none());
assert!(config.token_expires_at.is_none());
assert!(config.refresh_token_expires_at.is_none());
}
#[test]
fn test_config_serialization() {
let config = Config {
username: Some("test".to_string()),
access_token: Some("token".to_string()),
refresh_token: Some("refresh".to_string()),
token_expires_at: Some("2099-01-01T00:00:00Z".to_string()),
refresh_token_expires_at: Some("2099-04-01T00:00:00Z".to_string()),
extra: {
let mut map = HashMap::new();
map.insert("custom_key".to_string(), serde_json::json!("custom_value"));
map
},
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("test"));
assert!(json.contains("custom_key"));
let deserialized: Config = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.username, config.username);
assert_eq!(
deserialized.extra.get("custom_key").unwrap(),
"custom_value"
);
}
}