1use serde::{Deserialize, Serialize};
2#[cfg(unix)]
3use std::os::unix::fs::PermissionsExt;
4use std::{collections::BTreeMap, env, path::PathBuf};
5
6use crate::error::{JiraError, Result};
7
8#[cfg(unix)]
11fn warn_if_world_readable(path: &std::path::Path) {
12 let Ok(meta) = std::fs::metadata(path) else {
13 return;
14 };
15 let mode = meta.permissions().mode() & 0o077;
16 if mode != 0 {
17 eprintln!(
18 "warning: {} is group/world accessible (mode {:o}); contains an API token. \
19 Run: chmod 600 {}",
20 path.display(),
21 meta.permissions().mode() & 0o777,
22 path.display(),
23 );
24 }
25}
26
27#[cfg(not(unix))]
28fn warn_if_world_readable(_path: &std::path::Path) {}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum JiraDeployment {
33 #[default]
34 Cloud,
35 DataCenter,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum JiraAuthType {
41 #[default]
42 CloudApiToken,
43 DataCenterPat,
44 DataCenterBasic,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct JiraConfig {
49 #[serde(default)]
50 pub profile_name: Option<String>,
51 pub base_url: String,
52 pub email: String,
53 pub token: Option<String>,
54 pub project: Option<String>,
55 pub timeout_secs: u64,
56 #[serde(default)]
57 pub deployment: JiraDeployment,
58 #[serde(default)]
59 pub auth_type: JiraAuthType,
60 #[serde(default = "default_api_version")]
61 pub api_version: u8,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct JiraProfileConfig {
66 pub base_url: String,
67 #[serde(default)]
68 pub email: String,
69 pub token: Option<String>,
70 pub project: Option<String>,
71 pub timeout_secs: u64,
72 #[serde(default)]
73 pub deployment: JiraDeployment,
74 #[serde(default)]
75 pub auth_type: JiraAuthType,
76 #[serde(default = "default_api_version")]
77 pub api_version: u8,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct JiraProfilesFile {
82 pub current_profile: Option<String>,
83 #[serde(default)]
84 pub profiles: BTreeMap<String, JiraProfileConfig>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88struct LegacyJiraConfig {
89 pub base_url: String,
90 pub email: String,
91 pub token: Option<String>,
92 pub project: Option<String>,
93 pub timeout_secs: u64,
94}
95
96fn default_api_version() -> u8 {
97 3
98}
99
100impl Default for JiraConfig {
101 fn default() -> Self {
102 Self {
103 profile_name: Some(default_profile_name()),
104 base_url: String::new(),
105 email: String::new(),
106 token: None,
107 project: None,
108 timeout_secs: 30,
109 deployment: JiraDeployment::Cloud,
110 auth_type: JiraAuthType::CloudApiToken,
111 api_version: default_api_version(),
112 }
113 }
114}
115
116impl Default for JiraProfileConfig {
117 fn default() -> Self {
118 JiraConfig::default().into_profile()
119 }
120}
121
122impl From<JiraProfileConfig> for JiraConfig {
123 fn from(value: JiraProfileConfig) -> Self {
124 let api_version = normalize_api_version(value.api_version, &value.deployment);
125 Self {
126 profile_name: None,
127 base_url: value.base_url,
128 email: value.email,
129 token: value.token,
130 project: value.project,
131 timeout_secs: value.timeout_secs,
132 deployment: value.deployment,
133 auth_type: value.auth_type,
134 api_version,
135 }
136 }
137}
138
139impl JiraConfig {
140 pub fn load() -> Result<Self> {
142 let profile_override = env::var("JIRA_PROFILE")
143 .ok()
144 .filter(|s| !s.trim().is_empty());
145 let store = JiraProfilesFile::load()?;
146
147 let mut config = if let Some(profile_name) = profile_override.clone() {
148 store
149 .profiles
150 .get(&profile_name)
151 .cloned()
152 .map(Into::into)
153 .unwrap_or_else(JiraConfig::default)
154 } else {
155 store.active_profile().map(Into::into).unwrap_or_default()
156 };
157
158 config.profile_name = Some(
159 profile_override
160 .or_else(|| store.current_profile.clone())
161 .unwrap_or_else(default_profile_name),
162 );
163 config.apply_env_overrides();
164 config.api_version = normalize_api_version(config.api_version, &config.deployment);
165
166 Ok(config)
167 }
168
169 pub fn into_profile(self) -> JiraProfileConfig {
170 let api_version = normalize_api_version(self.api_version, &self.deployment);
171 JiraProfileConfig {
172 base_url: self.base_url,
173 email: self.email,
174 token: self.token,
175 project: self.project,
176 timeout_secs: self.timeout_secs,
177 deployment: self.deployment,
178 auth_type: self.auth_type,
179 api_version,
180 }
181 }
182
183 pub fn save(&self) -> Result<()> {
184 let profile_name = self
185 .profile_name
186 .clone()
187 .filter(|name| !name.trim().is_empty())
188 .unwrap_or_else(default_profile_name);
189
190 let mut store = JiraProfilesFile::load()?;
191 store.current_profile = Some(profile_name.clone());
192 store
193 .profiles
194 .insert(profile_name, self.clone().into_profile().normalized());
195 store.save()
196 }
197
198 pub fn token_present(&self) -> bool {
199 self.token
200 .as_deref()
201 .map(|value| !value.trim().is_empty())
202 .unwrap_or(false)
203 }
204
205 pub fn requires_user_identity(&self) -> bool {
206 matches!(
207 self.auth_type,
208 JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic
209 )
210 }
211
212 pub fn credential_label(&self) -> &'static str {
213 match self.auth_type {
214 JiraAuthType::CloudApiToken => "API token",
215 JiraAuthType::DataCenterPat => "Personal access token",
216 JiraAuthType::DataCenterBasic => "Password or personal access token",
217 }
218 }
219
220 pub fn user_label(&self) -> &'static str {
221 match self.auth_type {
222 JiraAuthType::DataCenterBasic => "Username",
223 _ => "Email address",
224 }
225 }
226
227 pub fn auth_header_kind(&self) -> &'static str {
228 match self.auth_type {
229 JiraAuthType::DataCenterPat => "Bearer",
230 JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic => "Basic",
231 }
232 }
233
234 fn apply_env_overrides(&mut self) {
244 if let Ok(url) = env::var("JIRA_URL") {
245 self.base_url = url;
246 }
247 if let Ok(email) = env::var("JIRA_EMAIL") {
248 self.email = email;
249 }
250 if let Ok(token) = env::var("JIRA_TOKEN") {
251 self.token = Some(token);
252 }
253 if let Ok(project) = env::var("JIRA_PROJECT") {
254 self.project = if project.trim().is_empty() {
255 None
256 } else {
257 Some(project)
258 };
259 }
260 if let Ok(timeout_secs) = env::var("JIRA_TIMEOUT_SECS") {
261 if let Ok(value) = timeout_secs.parse::<u64>() {
262 self.timeout_secs = value;
263 }
264 }
265 if let Ok(deployment) = env::var("JIRA_DEPLOYMENT") {
266 if let Some(value) = parse_deployment(&deployment) {
267 self.deployment = value;
268 }
269 }
270 if let Ok(auth_type) = env::var("JIRA_AUTH_TYPE") {
271 if let Some(value) = parse_auth_type(&auth_type) {
272 self.auth_type = value;
273 }
274 }
275 if let Ok(api_version) = env::var("JIRA_API_VERSION") {
276 if let Ok(value) = api_version.parse::<u8>() {
277 self.api_version = value;
278 }
279 }
280 }
281}
282
283impl JiraProfileConfig {
284 fn normalized(mut self) -> Self {
285 self.api_version = normalize_api_version(self.api_version, &self.deployment);
286 self
287 }
288}
289
290impl JiraProfilesFile {
291 pub fn load() -> Result<Self> {
292 let config_path = config_file_path();
293 if !config_path.exists() {
294 return Ok(Self::default());
295 }
296
297 warn_if_world_readable(&config_path);
298
299 let content = std::fs::read_to_string(&config_path)
300 .map_err(|e| JiraError::Config(format!("Failed to read config: {e}")))?;
301
302 let parsed: toml::Value = toml::from_str(&content)
303 .map_err(|e| JiraError::Config(format!("Failed to parse config: {e}")))?;
304
305 if parsed.get("profiles").is_some() || parsed.get("current_profile").is_some() {
306 let mut store: JiraProfilesFile = toml::from_str(&content)
307 .map_err(|e| JiraError::Config(format!("Failed to parse config: {e}")))?;
308 for profile in store.profiles.values_mut() {
309 profile.api_version =
310 normalize_api_version(profile.api_version, &profile.deployment);
311 }
312 return Ok(store);
313 }
314
315 let legacy: LegacyJiraConfig = toml::from_str(&content)
316 .map_err(|e| JiraError::Config(format!("Failed to parse legacy config: {e}")))?;
317
318 let mut profiles = BTreeMap::new();
319 profiles.insert(
320 default_profile_name(),
321 JiraProfileConfig {
322 base_url: legacy.base_url,
323 email: legacy.email,
324 token: legacy.token,
325 project: legacy.project,
326 timeout_secs: legacy.timeout_secs,
327 deployment: JiraDeployment::Cloud,
328 auth_type: JiraAuthType::CloudApiToken,
329 api_version: default_api_version(),
330 },
331 );
332
333 Ok(Self {
334 current_profile: Some(default_profile_name()),
335 profiles,
336 })
337 }
338
339 pub fn save(&self) -> Result<()> {
340 let config_path = config_file_path();
341 if let Some(parent) = config_path.parent() {
342 std::fs::create_dir_all(parent)
343 .map_err(|e| JiraError::Config(format!("Failed to create config dir: {e}")))?;
344 }
345
346 let toml_str = toml::to_string_pretty(self)
347 .map_err(|e| JiraError::Config(format!("Failed to serialize config: {e}")))?;
348
349 std::fs::write(&config_path, toml_str)
350 .map_err(|e| JiraError::Config(format!("Failed to write config: {e}")))?;
351
352 #[cfg(unix)]
353 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
354 .map_err(|e| JiraError::Config(format!("Failed to set config permissions: {e}")))?;
355
356 Ok(())
357 }
358
359 pub fn active_profile(&self) -> Option<JiraProfileConfig> {
360 let name = self
361 .current_profile
362 .clone()
363 .or_else(|| self.profiles.keys().next().cloned())?;
364 self.profiles.get(&name).cloned()
365 }
366
367 pub fn current_profile_name(&self) -> Option<String> {
368 self.current_profile
369 .clone()
370 .or_else(|| self.profiles.keys().next().cloned())
371 }
372
373 pub fn set_current_profile(&mut self, profile_name: &str) -> Result<()> {
374 if !self.profiles.contains_key(profile_name) {
375 return Err(JiraError::Config(format!(
376 "Profile not found: {profile_name}"
377 )));
378 }
379 self.current_profile = Some(profile_name.to_string());
380 Ok(())
381 }
382
383 pub fn remove_profile(&mut self, profile_name: &str) -> Result<()> {
384 if self.profiles.remove(profile_name).is_none() {
385 return Err(JiraError::Config(format!(
386 "Profile not found: {profile_name}"
387 )));
388 }
389 if self.current_profile.as_deref() == Some(profile_name) {
390 self.current_profile = self.profiles.keys().next().cloned();
391 }
392 Ok(())
393 }
394}
395
396pub fn config_file_path() -> PathBuf {
397 dirs::config_dir()
398 .unwrap_or_else(|| PathBuf::from("."))
399 .join("jira")
400 .join("config.toml")
401}
402
403pub fn parse_deployment(value: &str) -> Option<JiraDeployment> {
404 match value.trim().to_ascii_lowercase().as_str() {
405 "cloud" => Some(JiraDeployment::Cloud),
406 "datacenter" | "data_center" | "data-center" | "dc" | "self-managed" | "self_managed" => {
407 Some(JiraDeployment::DataCenter)
408 }
409 _ => None,
410 }
411}
412
413pub fn parse_auth_type(value: &str) -> Option<JiraAuthType> {
414 match value.trim().to_ascii_lowercase().as_str() {
415 "cloud_api_token" | "cloud-api-token" | "cloud" | "api-token" | "api_token" => {
416 Some(JiraAuthType::CloudApiToken)
417 }
418 "datacenter_pat" | "datacenter-pat" | "data_center_pat" | "dc-pat" | "pat" => {
419 Some(JiraAuthType::DataCenterPat)
420 }
421 "datacenter_basic" | "datacenter-basic" | "data_center_basic" | "dc-basic" | "basic" => {
422 Some(JiraAuthType::DataCenterBasic)
423 }
424 _ => None,
425 }
426}
427
428pub fn normalize_api_version(api_version: u8, deployment: &JiraDeployment) -> u8 {
429 if api_version == 0 {
430 match deployment {
431 JiraDeployment::Cloud => 3,
432 JiraDeployment::DataCenter => 2,
433 }
434 } else {
435 api_version
436 }
437}
438
439pub fn default_profile_name() -> String {
440 "default".to_string()
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use std::sync::{Mutex, OnceLock};
447 use tempfile::TempDir;
448
449 fn env_lock() -> &'static Mutex<()> {
450 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
451 LOCK.get_or_init(|| Mutex::new(()))
452 }
453
454 fn set_config_home(temp_dir: &TempDir) {
455 std::env::set_var("XDG_CONFIG_HOME", temp_dir.path());
456 std::env::set_var("HOME", temp_dir.path());
457 std::env::set_var("USERPROFILE", temp_dir.path());
458 std::env::set_var("APPDATA", temp_dir.path());
459 std::env::set_var("LOCALAPPDATA", temp_dir.path());
460 }
461
462 fn clear_config_home() {
463 std::env::remove_var("XDG_CONFIG_HOME");
464 std::env::remove_var("HOME");
465 std::env::remove_var("USERPROFILE");
466 std::env::remove_var("APPDATA");
467 std::env::remove_var("LOCALAPPDATA");
468 std::env::remove_var("JIRA_URL");
469 std::env::remove_var("JIRA_EMAIL");
470 std::env::remove_var("JIRA_TOKEN");
471 std::env::remove_var("JIRA_PROFILE");
472 std::env::remove_var("JIRA_DEPLOYMENT");
473 std::env::remove_var("JIRA_AUTH_TYPE");
474 std::env::remove_var("JIRA_API_VERSION");
475 }
476
477 #[test]
478 fn migrates_legacy_config_into_default_profile() {
479 let _guard = env_lock().lock().expect("env lock");
480 let temp_dir = TempDir::new().expect("tempdir");
481 clear_config_home();
482 set_config_home(&temp_dir);
483
484 std::fs::create_dir_all(config_file_path().parent().expect("parent")).expect("mkdir");
485 std::fs::write(
486 config_file_path(),
487 r#"base_url = "https://example.atlassian.net"
488email = "dev@example.com"
489token = "secret"
490project = "PROJ"
491timeout_secs = 55
492"#,
493 )
494 .expect("write");
495
496 let config = JiraConfig::load().expect("load legacy");
497 assert_eq!(config.profile_name.as_deref(), Some("default"));
498 assert_eq!(config.base_url, "https://example.atlassian.net");
499 assert_eq!(config.auth_type, JiraAuthType::CloudApiToken);
500 assert_eq!(config.api_version, 3);
501
502 clear_config_home();
503 }
504
505 #[test]
506 fn loads_from_env_without_config_file() {
507 let _guard = env_lock().lock().expect("env lock");
508 let temp_dir = TempDir::new().expect("tempdir");
509 clear_config_home();
510 set_config_home(&temp_dir);
511
512 std::env::set_var("JIRA_URL", "https://env-only.atlassian.net");
513 std::env::set_var("JIRA_EMAIL", "env@example.com");
514 std::env::set_var("JIRA_TOKEN", "env-secret");
515 std::env::set_var("JIRA_PROJECT", "ENV");
516
517 let config = JiraConfig::load().expect("load env-only");
518 assert_eq!(config.base_url, "https://env-only.atlassian.net");
519 assert_eq!(config.email, "env@example.com");
520 assert_eq!(config.token.as_deref(), Some("env-secret"));
521 assert_eq!(config.project.as_deref(), Some("ENV"));
522
523 clear_config_home();
524 }
525
526 #[test]
527 fn loads_named_profile_and_applies_env_overrides() {
528 let _guard = env_lock().lock().expect("env lock");
529 let temp_dir = TempDir::new().expect("tempdir");
530 clear_config_home();
531 set_config_home(&temp_dir);
532
533 let store = JiraProfilesFile {
534 current_profile: Some("cloud-main".into()),
535 profiles: BTreeMap::from([
536 (
537 "cloud-main".into(),
538 JiraProfileConfig {
539 base_url: "https://example.atlassian.net".into(),
540 email: "cloud@example.com".into(),
541 token: Some("cloud-token".into()),
542 project: Some("CLOUD".into()),
543 timeout_secs: 30,
544 deployment: JiraDeployment::Cloud,
545 auth_type: JiraAuthType::CloudApiToken,
546 api_version: 3,
547 },
548 ),
549 (
550 "dc-main".into(),
551 JiraProfileConfig {
552 base_url: "https://jira.internal".into(),
553 email: String::new(),
554 token: Some("dc-token".into()),
555 project: Some("DC".into()),
556 timeout_secs: 40,
557 deployment: JiraDeployment::DataCenter,
558 auth_type: JiraAuthType::DataCenterPat,
559 api_version: 2,
560 },
561 ),
562 ]),
563 };
564 store.save().expect("save");
565
566 std::env::set_var("JIRA_PROFILE", "dc-main");
567 std::env::set_var("JIRA_PROJECT", "OPS");
568
569 let config = JiraConfig::load().expect("load profile");
570 assert_eq!(config.profile_name.as_deref(), Some("dc-main"));
571 assert_eq!(config.base_url, "https://jira.internal");
572 assert_eq!(config.project.as_deref(), Some("OPS"));
573 assert_eq!(config.auth_type, JiraAuthType::DataCenterPat);
574 assert_eq!(config.api_version, 2);
575
576 clear_config_home();
577 }
578}