use serde::{Deserialize, Serialize};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{collections::BTreeMap, env, path::PathBuf};
use crate::error::{JiraError, Result};
#[cfg(unix)]
fn warn_if_world_readable(path: &std::path::Path) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
eprintln!(
"warning: {} is group/world accessible (mode {:o}); contains an API token. \
Run: chmod 600 {}",
path.display(),
meta.permissions().mode() & 0o777,
path.display(),
);
}
}
#[cfg(not(unix))]
fn warn_if_world_readable(_path: &std::path::Path) {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum JiraDeployment {
#[default]
Cloud,
DataCenter,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum JiraAuthType {
#[default]
CloudApiToken,
DataCenterPat,
DataCenterBasic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraConfig {
#[serde(default)]
pub profile_name: Option<String>,
pub base_url: String,
pub email: String,
pub token: Option<String>,
pub project: Option<String>,
pub timeout_secs: u64,
#[serde(default)]
pub deployment: JiraDeployment,
#[serde(default)]
pub auth_type: JiraAuthType,
#[serde(default = "default_api_version")]
pub api_version: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraProfileConfig {
pub base_url: String,
#[serde(default)]
pub email: String,
pub token: Option<String>,
pub project: Option<String>,
pub timeout_secs: u64,
#[serde(default)]
pub deployment: JiraDeployment,
#[serde(default)]
pub auth_type: JiraAuthType,
#[serde(default = "default_api_version")]
pub api_version: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct JiraProfilesFile {
pub current_profile: Option<String>,
#[serde(default)]
pub profiles: BTreeMap<String, JiraProfileConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LegacyJiraConfig {
pub base_url: String,
pub email: String,
pub token: Option<String>,
pub project: Option<String>,
pub timeout_secs: u64,
}
fn default_api_version() -> u8 {
3
}
impl Default for JiraConfig {
fn default() -> Self {
Self {
profile_name: Some(default_profile_name()),
base_url: String::new(),
email: String::new(),
token: None,
project: None,
timeout_secs: 30,
deployment: JiraDeployment::Cloud,
auth_type: JiraAuthType::CloudApiToken,
api_version: default_api_version(),
}
}
}
impl Default for JiraProfileConfig {
fn default() -> Self {
JiraConfig::default().into_profile()
}
}
impl From<JiraProfileConfig> for JiraConfig {
fn from(value: JiraProfileConfig) -> Self {
let api_version = normalize_api_version(value.api_version, &value.deployment);
Self {
profile_name: None,
base_url: value.base_url,
email: value.email,
token: value.token,
project: value.project,
timeout_secs: value.timeout_secs,
deployment: value.deployment,
auth_type: value.auth_type,
api_version,
}
}
}
impl JiraConfig {
pub fn load() -> Result<Self> {
let profile_override = env::var("JIRA_PROFILE")
.ok()
.filter(|s| !s.trim().is_empty());
let store = JiraProfilesFile::load()?;
let mut config = if let Some(profile_name) = profile_override.clone() {
store
.profiles
.get(&profile_name)
.cloned()
.map(Into::into)
.unwrap_or_else(JiraConfig::default)
} else {
store.active_profile().map(Into::into).unwrap_or_default()
};
config.profile_name = Some(
profile_override
.or_else(|| store.current_profile.clone())
.unwrap_or_else(default_profile_name),
);
config.apply_env_overrides();
config.api_version = normalize_api_version(config.api_version, &config.deployment);
Ok(config)
}
pub fn into_profile(self) -> JiraProfileConfig {
let api_version = normalize_api_version(self.api_version, &self.deployment);
JiraProfileConfig {
base_url: self.base_url,
email: self.email,
token: self.token,
project: self.project,
timeout_secs: self.timeout_secs,
deployment: self.deployment,
auth_type: self.auth_type,
api_version,
}
}
pub fn save(&self) -> Result<()> {
let profile_name = self
.profile_name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(default_profile_name);
let mut store = JiraProfilesFile::load()?;
store.current_profile = Some(profile_name.clone());
store
.profiles
.insert(profile_name, self.clone().into_profile().normalized());
store.save()
}
pub fn token_present(&self) -> bool {
self.token
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
}
pub fn requires_user_identity(&self) -> bool {
matches!(
self.auth_type,
JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic
)
}
pub fn credential_label(&self) -> &'static str {
match self.auth_type {
JiraAuthType::CloudApiToken => "API token",
JiraAuthType::DataCenterPat => "Personal access token",
JiraAuthType::DataCenterBasic => "Password or personal access token",
}
}
pub fn user_label(&self) -> &'static str {
match self.auth_type {
JiraAuthType::DataCenterBasic => "Username",
_ => "Email address",
}
}
pub fn auth_header_kind(&self) -> &'static str {
match self.auth_type {
JiraAuthType::DataCenterPat => "Bearer",
JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic => "Basic",
}
}
fn apply_env_overrides(&mut self) {
if let Ok(url) = env::var("JIRA_URL") {
self.base_url = url;
}
if let Ok(email) = env::var("JIRA_EMAIL") {
self.email = email;
}
if let Ok(token) = env::var("JIRA_TOKEN") {
self.token = Some(token);
}
if let Ok(project) = env::var("JIRA_PROJECT") {
self.project = if project.trim().is_empty() {
None
} else {
Some(project)
};
}
if let Ok(timeout_secs) = env::var("JIRA_TIMEOUT_SECS") {
if let Ok(value) = timeout_secs.parse::<u64>() {
self.timeout_secs = value;
}
}
if let Ok(deployment) = env::var("JIRA_DEPLOYMENT") {
if let Some(value) = parse_deployment(&deployment) {
self.deployment = value;
}
}
if let Ok(auth_type) = env::var("JIRA_AUTH_TYPE") {
if let Some(value) = parse_auth_type(&auth_type) {
self.auth_type = value;
}
}
if let Ok(api_version) = env::var("JIRA_API_VERSION") {
if let Ok(value) = api_version.parse::<u8>() {
self.api_version = value;
}
}
}
}
impl JiraProfileConfig {
fn normalized(mut self) -> Self {
self.api_version = normalize_api_version(self.api_version, &self.deployment);
self
}
}
impl JiraProfilesFile {
pub fn load() -> Result<Self> {
let config_path = config_file_path();
if !config_path.exists() {
return Ok(Self::default());
}
warn_if_world_readable(&config_path);
let content = std::fs::read_to_string(&config_path)
.map_err(|e| JiraError::Config(format!("Failed to read config: {e}")))?;
let parsed: toml::Value = toml::from_str(&content)
.map_err(|e| JiraError::Config(format!("Failed to parse config: {e}")))?;
if parsed.get("profiles").is_some() || parsed.get("current_profile").is_some() {
let mut store: JiraProfilesFile = toml::from_str(&content)
.map_err(|e| JiraError::Config(format!("Failed to parse config: {e}")))?;
for profile in store.profiles.values_mut() {
profile.api_version =
normalize_api_version(profile.api_version, &profile.deployment);
}
return Ok(store);
}
let legacy: LegacyJiraConfig = toml::from_str(&content)
.map_err(|e| JiraError::Config(format!("Failed to parse legacy config: {e}")))?;
let mut profiles = BTreeMap::new();
profiles.insert(
default_profile_name(),
JiraProfileConfig {
base_url: legacy.base_url,
email: legacy.email,
token: legacy.token,
project: legacy.project,
timeout_secs: legacy.timeout_secs,
deployment: JiraDeployment::Cloud,
auth_type: JiraAuthType::CloudApiToken,
api_version: default_api_version(),
},
);
Ok(Self {
current_profile: Some(default_profile_name()),
profiles,
})
}
pub fn save(&self) -> Result<()> {
let config_path = config_file_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| JiraError::Config(format!("Failed to create config dir: {e}")))?;
}
let toml_str = toml::to_string_pretty(self)
.map_err(|e| JiraError::Config(format!("Failed to serialize config: {e}")))?;
std::fs::write(&config_path, toml_str)
.map_err(|e| JiraError::Config(format!("Failed to write config: {e}")))?;
#[cfg(unix)]
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| JiraError::Config(format!("Failed to set config permissions: {e}")))?;
Ok(())
}
pub fn active_profile(&self) -> Option<JiraProfileConfig> {
let name = self
.current_profile
.clone()
.or_else(|| self.profiles.keys().next().cloned())?;
self.profiles.get(&name).cloned()
}
pub fn current_profile_name(&self) -> Option<String> {
self.current_profile
.clone()
.or_else(|| self.profiles.keys().next().cloned())
}
pub fn set_current_profile(&mut self, profile_name: &str) -> Result<()> {
if !self.profiles.contains_key(profile_name) {
return Err(JiraError::Config(format!(
"Profile not found: {profile_name}"
)));
}
self.current_profile = Some(profile_name.to_string());
Ok(())
}
pub fn remove_profile(&mut self, profile_name: &str) -> Result<()> {
if self.profiles.remove(profile_name).is_none() {
return Err(JiraError::Config(format!(
"Profile not found: {profile_name}"
)));
}
if self.current_profile.as_deref() == Some(profile_name) {
self.current_profile = self.profiles.keys().next().cloned();
}
Ok(())
}
}
pub fn config_file_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("jira")
.join("config.toml")
}
pub fn parse_deployment(value: &str) -> Option<JiraDeployment> {
match value.trim().to_ascii_lowercase().as_str() {
"cloud" => Some(JiraDeployment::Cloud),
"datacenter" | "data_center" | "data-center" | "dc" | "self-managed" | "self_managed" => {
Some(JiraDeployment::DataCenter)
}
_ => None,
}
}
pub fn parse_auth_type(value: &str) -> Option<JiraAuthType> {
match value.trim().to_ascii_lowercase().as_str() {
"cloud_api_token" | "cloud-api-token" | "cloud" | "api-token" | "api_token" => {
Some(JiraAuthType::CloudApiToken)
}
"datacenter_pat" | "datacenter-pat" | "data_center_pat" | "dc-pat" | "pat" => {
Some(JiraAuthType::DataCenterPat)
}
"datacenter_basic" | "datacenter-basic" | "data_center_basic" | "dc-basic" | "basic" => {
Some(JiraAuthType::DataCenterBasic)
}
_ => None,
}
}
pub fn normalize_api_version(api_version: u8, deployment: &JiraDeployment) -> u8 {
if api_version == 0 {
match deployment {
JiraDeployment::Cloud => 3,
JiraDeployment::DataCenter => 2,
}
} else {
api_version
}
}
pub fn default_profile_name() -> String {
"default".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
use tempfile::TempDir;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn set_config_home(temp_dir: &TempDir) {
std::env::set_var("XDG_CONFIG_HOME", temp_dir.path());
std::env::set_var("HOME", temp_dir.path());
std::env::set_var("USERPROFILE", temp_dir.path());
std::env::set_var("APPDATA", temp_dir.path());
std::env::set_var("LOCALAPPDATA", temp_dir.path());
}
fn clear_config_home() {
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("HOME");
std::env::remove_var("USERPROFILE");
std::env::remove_var("APPDATA");
std::env::remove_var("LOCALAPPDATA");
std::env::remove_var("JIRA_URL");
std::env::remove_var("JIRA_EMAIL");
std::env::remove_var("JIRA_TOKEN");
std::env::remove_var("JIRA_PROFILE");
std::env::remove_var("JIRA_DEPLOYMENT");
std::env::remove_var("JIRA_AUTH_TYPE");
std::env::remove_var("JIRA_API_VERSION");
}
#[test]
fn migrates_legacy_config_into_default_profile() {
let _guard = env_lock().lock().expect("env lock");
let temp_dir = TempDir::new().expect("tempdir");
clear_config_home();
set_config_home(&temp_dir);
std::fs::create_dir_all(config_file_path().parent().expect("parent")).expect("mkdir");
std::fs::write(
config_file_path(),
r#"base_url = "https://example.atlassian.net"
email = "dev@example.com"
token = "secret"
project = "PROJ"
timeout_secs = 55
"#,
)
.expect("write");
let config = JiraConfig::load().expect("load legacy");
assert_eq!(config.profile_name.as_deref(), Some("default"));
assert_eq!(config.base_url, "https://example.atlassian.net");
assert_eq!(config.auth_type, JiraAuthType::CloudApiToken);
assert_eq!(config.api_version, 3);
clear_config_home();
}
#[test]
fn loads_from_env_without_config_file() {
let _guard = env_lock().lock().expect("env lock");
let temp_dir = TempDir::new().expect("tempdir");
clear_config_home();
set_config_home(&temp_dir);
std::env::set_var("JIRA_URL", "https://env-only.atlassian.net");
std::env::set_var("JIRA_EMAIL", "env@example.com");
std::env::set_var("JIRA_TOKEN", "env-secret");
std::env::set_var("JIRA_PROJECT", "ENV");
let config = JiraConfig::load().expect("load env-only");
assert_eq!(config.base_url, "https://env-only.atlassian.net");
assert_eq!(config.email, "env@example.com");
assert_eq!(config.token.as_deref(), Some("env-secret"));
assert_eq!(config.project.as_deref(), Some("ENV"));
clear_config_home();
}
#[test]
fn loads_named_profile_and_applies_env_overrides() {
let _guard = env_lock().lock().expect("env lock");
let temp_dir = TempDir::new().expect("tempdir");
clear_config_home();
set_config_home(&temp_dir);
let store = JiraProfilesFile {
current_profile: Some("cloud-main".into()),
profiles: BTreeMap::from([
(
"cloud-main".into(),
JiraProfileConfig {
base_url: "https://example.atlassian.net".into(),
email: "cloud@example.com".into(),
token: Some("cloud-token".into()),
project: Some("CLOUD".into()),
timeout_secs: 30,
deployment: JiraDeployment::Cloud,
auth_type: JiraAuthType::CloudApiToken,
api_version: 3,
},
),
(
"dc-main".into(),
JiraProfileConfig {
base_url: "https://jira.internal".into(),
email: String::new(),
token: Some("dc-token".into()),
project: Some("DC".into()),
timeout_secs: 40,
deployment: JiraDeployment::DataCenter,
auth_type: JiraAuthType::DataCenterPat,
api_version: 2,
},
),
]),
};
store.save().expect("save");
std::env::set_var("JIRA_PROFILE", "dc-main");
std::env::set_var("JIRA_PROJECT", "OPS");
let config = JiraConfig::load().expect("load profile");
assert_eq!(config.profile_name.as_deref(), Some("dc-main"));
assert_eq!(config.base_url, "https://jira.internal");
assert_eq!(config.project.as_deref(), Some("OPS"));
assert_eq!(config.auth_type, JiraAuthType::DataCenterPat);
assert_eq!(config.api_version, 2);
clear_config_home();
}
}