use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
use super::token_type::TokenType;
#[derive(Debug, Error)]
pub enum ProfileError {
#[error("Profile name already exists: {0}")]
DuplicateName(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Profile {
pub team_id: String,
pub user_id: String,
pub team_name: Option<String>,
pub user_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bot_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_token_type: Option<TokenType>,
}
impl Profile {
pub fn get_bot_scopes(&self) -> Option<Vec<String>> {
self.bot_scopes.clone().or_else(|| self.scopes.clone())
}
pub fn get_user_scopes(&self) -> Option<Vec<String>> {
self.user_scopes.clone()
}
#[allow(clippy::too_many_arguments)]
pub fn with_scopes(
team_id: String,
user_id: String,
team_name: Option<String>,
user_name: Option<String>,
client_id: Option<String>,
redirect_uri: Option<String>,
bot_scopes: Option<Vec<String>>,
user_scopes: Option<Vec<String>>,
) -> Self {
Self {
team_id,
user_id,
team_name,
user_name,
client_id,
redirect_uri,
scopes: None, bot_scopes,
user_scopes,
default_token_type: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProfilesConfig {
pub version: u32,
pub profiles: HashMap<String, Profile>,
}
impl ProfilesConfig {
pub fn new() -> Self {
Self {
version: 1,
profiles: HashMap::new(),
}
}
pub fn get(&self, name: &str) -> Option<&Profile> {
self.profiles.get(name)
}
pub fn set(&mut self, name: String, profile: Profile) {
self.profiles.insert(name, profile);
}
pub fn add(&mut self, name: String, profile: Profile) -> Result<(), ProfileError> {
if self.profiles.contains_key(&name) {
return Err(ProfileError::DuplicateName(name));
}
self.profiles.insert(name, profile);
Ok(())
}
pub fn set_or_update(&mut self, name: String, profile: Profile) -> Result<(), ProfileError> {
if let Some(existing) = self.profiles.get(&name) {
let existing_is_placeholder =
existing.team_id == "PLACEHOLDER" || existing.user_id == "PLACEHOLDER";
let profile_is_placeholder =
profile.team_id == "PLACEHOLDER" || profile.user_id == "PLACEHOLDER";
if existing_is_placeholder {
self.profiles.insert(name, profile);
return Ok(());
}
if profile_is_placeholder {
return Ok(());
}
if existing.team_id != profile.team_id || existing.user_id != profile.user_id {
return Err(ProfileError::DuplicateName(name));
}
self.profiles.insert(name, profile);
return Ok(());
}
if profile.team_id != "PLACEHOLDER" && profile.user_id != "PLACEHOLDER" {
if let Some((existing_name, _)) = self.profiles.iter().find(|(_, p)| {
p.team_id != "PLACEHOLDER"
&& p.user_id != "PLACEHOLDER"
&& p.team_id == profile.team_id
&& p.user_id == profile.user_id
}) {
let existing_name = existing_name.clone();
self.profiles.insert(existing_name, profile);
return Ok(());
}
}
self.profiles.insert(name, profile);
Ok(())
}
pub fn remove(&mut self, name: &str) -> Option<Profile> {
self.profiles.remove(name)
}
pub fn list_names(&self) -> Vec<String> {
self.profiles.keys().cloned().collect()
}
}
impl Default for ProfilesConfig {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profiles_config_new() {
let config = ProfilesConfig::new();
assert_eq!(config.version, 1);
assert!(config.profiles.is_empty());
}
#[test]
fn test_profiles_config_get_set() {
let mut config = ProfilesConfig::new();
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config.set("default".to_string(), profile.clone());
assert_eq!(config.get("default"), Some(&profile));
assert_eq!(config.get("nonexistent"), None);
}
#[test]
fn test_profiles_config_remove() {
let mut config = ProfilesConfig::new();
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config.set("test".to_string(), profile.clone());
let removed = config.remove("test");
assert_eq!(removed, Some(profile));
assert_eq!(config.get("test"), None);
}
#[test]
fn test_profiles_config_list_names() {
let mut config = ProfilesConfig::new();
config.set(
"profile1".to_string(),
Profile {
team_id: "T1".to_string(),
user_id: "U1".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
},
);
config.set(
"profile2".to_string(),
Profile {
team_id: "T2".to_string(),
user_id: "U2".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
},
);
let mut names = config.list_names();
names.sort();
assert_eq!(names, vec!["profile1", "profile2"]);
}
#[test]
fn test_profile_serialization() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let json = serde_json::to_string(&profile).unwrap();
let deserialized: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(profile, deserialized);
}
#[test]
fn test_profiles_config_serialization() {
let mut config = ProfilesConfig::new();
config.set(
"default".to_string(),
Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
},
);
let json = serde_json::to_string_pretty(&config).unwrap();
let deserialized: ProfilesConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_profiles_config_add_duplicate_name() {
let mut config = ProfilesConfig::new();
let profile1 = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let profile2 = Profile {
team_id: "T789".to_string(),
user_id: "U012".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
assert!(config.add("default".to_string(), profile1).is_ok());
let result = config.add("default".to_string(), profile2);
assert!(result.is_err());
match result {
Err(ProfileError::DuplicateName(name)) => {
assert_eq!(name, "default");
}
_ => panic!("Expected DuplicateName error"),
}
}
#[test]
fn test_profiles_config_set_or_update_new() {
let mut config = ProfilesConfig::new();
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
assert!(config
.set_or_update("default".to_string(), profile.clone())
.is_ok());
assert_eq!(config.get("default"), Some(&profile));
}
#[test]
fn test_profiles_config_set_or_update_same_identity() {
let mut config = ProfilesConfig::new();
let profile1 = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let profile2 = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Updated Team".to_string()),
user_name: Some("Updated User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config
.set_or_update("default".to_string(), profile1)
.unwrap();
assert!(config
.set_or_update("default".to_string(), profile2.clone())
.is_ok());
assert_eq!(config.get("default"), Some(&profile2));
}
#[test]
fn test_profiles_config_set_or_update_different_identity() {
let mut config = ProfilesConfig::new();
let profile1 = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let profile2 = Profile {
team_id: "T789".to_string(),
user_id: "U012".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config
.set_or_update("default".to_string(), profile1)
.unwrap();
let result = config.set_or_update("default".to_string(), profile2);
assert!(result.is_err());
match result {
Err(ProfileError::DuplicateName(_)) => {}
_ => panic!("Expected DuplicateName error"),
}
}
#[test]
fn test_profiles_config_set_or_update_same_identity_different_name() {
let mut config = ProfilesConfig::new();
let profile1 = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let profile2 = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Updated Team".to_string()),
user_name: Some("Updated User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config.set_or_update("old".to_string(), profile1).unwrap();
assert!(config
.set_or_update("new".to_string(), profile2.clone())
.is_ok());
assert_eq!(config.get("old"), Some(&profile2));
assert_eq!(config.get("new"), None);
}
#[test]
fn test_backward_compatibility_profile_without_client_id() {
let json = r#"{
"version": 1,
"profiles": {
"default": {
"team_id": "T123",
"user_id": "U456",
"team_name": "Test Team",
"user_name": "Test User"
}
}
}"#;
let config: ProfilesConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.profiles.len(), 1);
let profile = config.get("default").unwrap();
assert_eq!(profile.team_id, "T123");
assert_eq!(profile.user_id, "U456");
assert_eq!(profile.client_id, None);
}
#[test]
fn test_profile_with_client_id_serialization() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: Some("client-123".to_string()),
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let json = serde_json::to_string(&profile).unwrap();
let deserialized: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(profile, deserialized);
assert_eq!(deserialized.client_id, Some("client-123".to_string()));
}
#[test]
fn test_profile_without_client_id_omits_field() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let json = serde_json::to_string(&profile).unwrap();
assert!(!json.contains("client_id"));
}
#[test]
fn test_profile_with_oauth_config_serialization() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: Some("client-123".to_string()),
redirect_uri: Some("http://127.0.0.1:8765/callback".to_string()),
scopes: Some(vec!["chat:write".to_string(), "users:read".to_string()]),
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let json = serde_json::to_string(&profile).unwrap();
let deserialized: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(profile, deserialized);
assert_eq!(deserialized.client_id, Some("client-123".to_string()));
assert_eq!(
deserialized.redirect_uri,
Some("http://127.0.0.1:8765/callback".to_string())
);
assert_eq!(
deserialized.scopes,
Some(vec!["chat:write".to_string(), "users:read".to_string()])
);
}
#[test]
fn test_profile_without_oauth_config_omits_fields() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let json = serde_json::to_string(&profile).unwrap();
assert!(!json.contains("client_id"));
assert!(!json.contains("redirect_uri"));
assert!(!json.contains("scopes"));
}
#[test]
fn test_set_or_update_placeholder_to_real() {
let mut config = ProfilesConfig::new();
let placeholder_profile = Profile {
team_id: "PLACEHOLDER".to_string(),
user_id: "PLACEHOLDER".to_string(),
team_name: None,
user_name: None,
client_id: Some("client-123".to_string()),
redirect_uri: Some("http://localhost:8765/callback".to_string()),
scopes: Some(vec!["chat:write".to_string()]),
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config
.set_or_update("work".to_string(), placeholder_profile)
.unwrap();
let real_profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Real Team".to_string()),
user_name: Some("Real User".to_string()),
client_id: Some("client-123".to_string()),
redirect_uri: Some("http://localhost:8765/callback".to_string()),
scopes: Some(vec!["chat:write".to_string()]),
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
assert!(config
.set_or_update("work".to_string(), real_profile.clone())
.is_ok());
let updated = config.get("work").unwrap();
assert_eq!(updated.team_id, "T123");
assert_eq!(updated.user_id, "U456");
}
#[test]
fn test_set_or_update_real_to_placeholder_keeps_real() {
let mut config = ProfilesConfig::new();
let real_profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Real Team".to_string()),
user_name: Some("Real User".to_string()),
client_id: Some("client-123".to_string()),
redirect_uri: Some("http://localhost:8765/callback".to_string()),
scopes: Some(vec!["chat:write".to_string()]),
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config
.set_or_update("work".to_string(), real_profile.clone())
.unwrap();
let placeholder_profile = Profile {
team_id: "PLACEHOLDER".to_string(),
user_id: "PLACEHOLDER".to_string(),
team_name: None,
user_name: None,
client_id: Some("client-456".to_string()),
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
assert!(config
.set_or_update("work".to_string(), placeholder_profile)
.is_ok());
let updated = config.get("work").unwrap();
assert_eq!(updated.team_id, "T123");
assert_eq!(updated.user_id, "U456");
}
#[test]
fn test_backward_compatibility_scopes_to_bot_scopes() {
let json = r#"{
"version": 1,
"profiles": {
"default": {
"team_id": "T123",
"user_id": "U456",
"team_name": "Test Team",
"user_name": "Test User",
"scopes": ["chat:write", "users:read"]
}
}
}"#;
let config: ProfilesConfig = serde_json::from_str(json).unwrap();
let profile = config.get("default").unwrap();
assert_eq!(
profile.get_bot_scopes(),
Some(vec!["chat:write".to_string(), "users:read".to_string()])
);
assert_eq!(profile.get_user_scopes(), None);
}
#[test]
fn test_new_profile_with_bot_and_user_scopes() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: Some("client-123".to_string()),
redirect_uri: Some("http://localhost:8765/callback".to_string()),
scopes: None,
bot_scopes: Some(vec!["chat:write".to_string()]),
user_scopes: Some(vec!["users:read".to_string()]),
default_token_type: None,
};
assert_eq!(
profile.get_bot_scopes(),
Some(vec!["chat:write".to_string()])
);
assert_eq!(
profile.get_user_scopes(),
Some(vec!["users:read".to_string()])
);
}
#[test]
fn test_backward_compatibility_profile_without_default_token_type() {
let json = r#"{
"version": 1,
"profiles": {
"default": {
"team_id": "T123",
"user_id": "U456",
"team_name": "Test Team",
"user_name": "Test User"
}
}
}"#;
let config: ProfilesConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.profiles.len(), 1);
let profile = config.get("default").unwrap();
assert_eq!(profile.team_id, "T123");
assert_eq!(profile.user_id, "U456");
assert_eq!(profile.default_token_type, None);
}
#[test]
fn test_profile_with_default_token_type_serialization() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: Some(super::super::token_type::TokenType::Bot),
};
let json = serde_json::to_string(&profile).unwrap();
let deserialized: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(profile.team_id, deserialized.team_id);
assert_eq!(
deserialized.default_token_type,
Some(super::super::token_type::TokenType::Bot)
);
}
#[test]
fn test_profile_without_default_token_type_omits_field() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: Some("Test User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let json = serde_json::to_string(&profile).unwrap();
assert!(!json.contains("default_token_type"));
}
#[test]
fn test_set_or_update_placeholder_no_conflict_with_other_profiles() {
let mut config = ProfilesConfig::new();
let real_profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Real Team".to_string()),
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config
.set_or_update("existing".to_string(), real_profile)
.unwrap();
let placeholder_profile = Profile {
team_id: "PLACEHOLDER".to_string(),
user_id: "PLACEHOLDER".to_string(),
team_name: None,
user_name: None,
client_id: Some("client-789".to_string()),
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
assert!(config
.set_or_update("new".to_string(), placeholder_profile)
.is_ok());
assert!(config.get("existing").is_some());
assert!(config.get("new").is_some());
}
}
#[cfg(test)]
mod backward_compat_tests {
use super::*;
#[test]
fn test_get_bot_scopes_from_legacy_scopes() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: Some(vec!["chat:write".to_string(), "users:read".to_string()]),
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let bot_scopes = profile.get_bot_scopes();
assert_eq!(
bot_scopes,
Some(vec!["chat:write".to_string(), "users:read".to_string()])
);
}
#[test]
fn test_get_bot_scopes_prefers_bot_scopes_over_scopes() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: Some(vec!["old:scope".to_string()]),
bot_scopes: Some(vec!["new:scope".to_string()]),
user_scopes: None,
default_token_type: None,
};
let bot_scopes = profile.get_bot_scopes();
assert_eq!(bot_scopes, Some(vec!["new:scope".to_string()]));
}
#[test]
fn test_get_user_scopes_returns_none_for_legacy() {
let profile = Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: None,
user_name: None,
client_id: None,
redirect_uri: None,
scopes: Some(vec!["chat:write".to_string()]),
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
let user_scopes = profile.get_user_scopes();
assert_eq!(user_scopes, None);
}
#[test]
fn test_deserialize_old_profile_format() {
let json = r#"{
"team_id": "T123",
"user_id": "U456",
"team_name": "Test Team",
"user_name": "Test User",
"scopes": ["chat:write", "users:read"]
}"#;
let profile: Profile = serde_json::from_str(json).unwrap();
assert_eq!(profile.team_id, "T123");
assert_eq!(profile.user_id, "U456");
assert_eq!(
profile.scopes,
Some(vec!["chat:write".to_string(), "users:read".to_string()])
);
assert_eq!(profile.bot_scopes, None);
assert_eq!(profile.user_scopes, None);
let bot_scopes = profile.get_bot_scopes();
assert_eq!(
bot_scopes,
Some(vec!["chat:write".to_string(), "users:read".to_string()])
);
}
}