Skip to main content

jira_core/
config.rs

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/// Emit an `eprintln!` warning if the config file is readable by group/other.
9/// Tokens are stored in this file, so loose permissions are a security risk.
10#[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    /// Load the active profile from ~/.config/jira/config.toml and env vars.
141    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    /// Apply `JIRA_*` environment variables on top of the loaded profile.
235    ///
236    /// Precedence (highest first):
237    ///   1. Environment variables (`JIRA_URL`, `JIRA_EMAIL`, `JIRA_TOKEN`, ...)
238    ///   2. Profile values from the config file
239    ///   3. Built-in defaults
240    ///
241    /// Env vars always win because this runs *after* the file has been parsed
242    /// into `self`. Empty `JIRA_PROJECT` is treated as "unset" (clears project).
243    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}