Skip to main content

jira_cli/
config.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6use crate::api::ApiError;
7use crate::api::AuthType;
8use crate::output::OutputConfig;
9
10#[derive(Debug, Deserialize, Default, Clone)]
11pub struct ProfileConfig {
12    pub host: Option<String>,
13    pub email: Option<String>,
14    pub token: Option<String>,
15    pub auth_type: Option<String>,
16    pub api_version: Option<u8>,
17}
18
19#[derive(Debug, Deserialize, Default)]
20struct RawConfig {
21    #[serde(default)]
22    default: ProfileConfig,
23    #[serde(default)]
24    profiles: BTreeMap<String, ProfileConfig>,
25    host: Option<String>,
26    email: Option<String>,
27    token: Option<String>,
28    auth_type: Option<String>,
29    api_version: Option<u8>,
30}
31
32impl RawConfig {
33    fn default_profile(&self) -> ProfileConfig {
34        ProfileConfig {
35            host: self.default.host.clone().or_else(|| self.host.clone()),
36            email: self.default.email.clone().or_else(|| self.email.clone()),
37            token: self.default.token.clone().or_else(|| self.token.clone()),
38            auth_type: self
39                .default
40                .auth_type
41                .clone()
42                .or_else(|| self.auth_type.clone()),
43            api_version: self.default.api_version.or(self.api_version),
44        }
45    }
46}
47
48/// Resolved credentials for a single profile.
49#[derive(Debug, Clone)]
50pub struct Config {
51    pub host: String,
52    pub email: String,
53    pub token: String,
54    pub auth_type: AuthType,
55    pub api_version: u8,
56}
57
58impl Config {
59    /// Load config with priority: CLI args > env vars > config file.
60    ///
61    /// The API token must be supplied via the `JIRA_TOKEN` environment variable
62    /// or the config file — not via a CLI flag, to avoid leaking it in process
63    /// argument lists visible to other users.
64    pub fn load(
65        host_arg: Option<String>,
66        email_arg: Option<String>,
67        profile_arg: Option<String>,
68    ) -> Result<Self, ApiError> {
69        let file_profile = load_file_profile(profile_arg.as_deref())?;
70
71        let host = normalize_value(host_arg)
72            .or_else(|| env_var("JIRA_HOST"))
73            .or_else(|| normalize_value(file_profile.host))
74            .ok_or_else(|| {
75                ApiError::InvalidInput(
76                    "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
77                )
78            })?;
79
80        let token = env_var("JIRA_TOKEN")
81            .or_else(|| normalize_value(file_profile.token.clone()))
82            .ok_or_else(|| {
83                ApiError::InvalidInput(
84                    "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
85                )
86            })?;
87
88        let auth_type = env_var("JIRA_AUTH_TYPE")
89            .as_deref()
90            .map(|v| {
91                if v.eq_ignore_ascii_case("pat") {
92                    AuthType::Pat
93                } else {
94                    AuthType::Basic
95                }
96            })
97            .or_else(|| {
98                file_profile.auth_type.as_deref().map(|v| {
99                    if v.eq_ignore_ascii_case("pat") {
100                        AuthType::Pat
101                    } else {
102                        AuthType::Basic
103                    }
104                })
105            })
106            .unwrap_or_default();
107
108        let api_version = env_var("JIRA_API_VERSION")
109            .and_then(|v| v.parse::<u8>().ok())
110            .or(file_profile.api_version)
111            .unwrap_or(3);
112
113        // Email is required for Basic auth; PAT auth uses a token only.
114        let email = normalize_value(email_arg)
115            .or_else(|| env_var("JIRA_EMAIL"))
116            .or_else(|| normalize_value(file_profile.email));
117
118        let email = match auth_type {
119            AuthType::Basic => email.ok_or_else(|| {
120                ApiError::InvalidInput(
121                    "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
122                )
123            })?,
124            AuthType::Pat => email.unwrap_or_default(),
125        };
126
127        Ok(Self {
128            host,
129            email,
130            token,
131            auth_type,
132            api_version,
133        })
134    }
135}
136
137fn config_path() -> PathBuf {
138    config_dir()
139        .unwrap_or_else(|| PathBuf::from(".config"))
140        .join("jira")
141        .join("config.toml")
142}
143
144pub fn schema_config_path() -> String {
145    config_path().display().to_string()
146}
147
148pub fn schema_config_path_description() -> &'static str {
149    #[cfg(target_os = "windows")]
150    {
151        "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
152    }
153
154    #[cfg(not(target_os = "windows"))]
155    {
156        "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
157    }
158}
159
160pub fn recommended_permissions(path: &std::path::Path) -> String {
161    #[cfg(target_os = "windows")]
162    {
163        format!(
164            "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
165            path.display()
166        )
167    }
168
169    #[cfg(not(target_os = "windows"))]
170    {
171        format!("chmod 600 {}", path.display())
172    }
173}
174
175pub fn schema_recommended_permissions_example() -> &'static str {
176    #[cfg(target_os = "windows")]
177    {
178        "Keep the file in your per-user %APPDATA% directory and out of shared folders."
179    }
180
181    #[cfg(not(target_os = "windows"))]
182    {
183        "chmod 600 /path/to/config.toml"
184    }
185}
186
187fn config_dir() -> Option<PathBuf> {
188    #[cfg(target_os = "windows")]
189    {
190        dirs::config_dir()
191    }
192
193    #[cfg(not(target_os = "windows"))]
194    {
195        std::env::var_os("XDG_CONFIG_HOME")
196            .filter(|value| !value.is_empty())
197            .map(PathBuf::from)
198            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
199    }
200}
201
202fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
203    let path = config_path();
204    let content = match std::fs::read_to_string(&path) {
205        Ok(c) => c,
206        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
207        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
208    };
209
210    let raw: RawConfig = toml::from_str(&content)
211        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
212
213    let profile_name = normalize_str(profile)
214        .map(str::to_owned)
215        .or_else(|| env_var("JIRA_PROFILE"));
216
217    match profile_name {
218        Some(name) => {
219            // BTreeMap gives sorted, deterministic output in error messages
220            let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
221            raw.profiles.get(&name).cloned().ok_or_else(|| {
222                ApiError::Other(format!(
223                    "Profile '{name}' not found in config. Available: {}",
224                    available.join(", ")
225                ))
226            })
227        }
228        None => Ok(raw.default_profile()),
229    }
230}
231
232/// Print the config file path and current resolved values (masking the token).
233pub fn show(
234    out: &OutputConfig,
235    host_arg: Option<String>,
236    email_arg: Option<String>,
237    profile_arg: Option<String>,
238) -> Result<(), ApiError> {
239    let path = config_path();
240    let cfg = Config::load(host_arg, email_arg, profile_arg)?;
241    let masked = mask_token(&cfg.token);
242
243    if out.json {
244        out.print_data(
245            &serde_json::to_string_pretty(&serde_json::json!({
246                "configPath": path,
247                "host": cfg.host,
248                "email": cfg.email,
249                "tokenMasked": masked,
250            }))
251            .expect("failed to serialize JSON"),
252        );
253    } else {
254        out.print_message(&format!("Config file: {}", path.display()));
255        out.print_data(&format!(
256            "host:  {}\nemail: {}\ntoken: {masked}",
257            cfg.host, cfg.email
258        ));
259    }
260    Ok(())
261}
262
263/// Print example config file and instructions for obtaining an API token.
264///
265/// Pass `host` (e.g. `"jira.mycompany.com"`) to include a one-click URL to the
266/// Personal Access Token creation page on a Jira DC/Server instance. When omitted
267/// the URL is shown as a template placeholder.
268pub fn init(out: &OutputConfig, host: Option<&str>) {
269    let path = config_path();
270    let path_resolution = schema_config_path_description();
271    let permission_advice = recommended_permissions(&path);
272    let example = serde_json::json!({
273        "default": {
274            "host": "mycompany.atlassian.net",
275            "email": "me@example.com",
276            "token": "your-api-token",
277            "auth_type": "basic",
278            "api_version": 3,
279        },
280        "profiles": {
281            "work": {
282                "host": "work.atlassian.net",
283                "email": "me@work.com",
284                "token": "work-token",
285            },
286            "datacenter": {
287                "host": "jira.mycompany.com",
288                "token": "your-personal-access-token",
289                "auth_type": "pat",
290                "api_version": 2,
291            }
292        }
293    });
294
295    const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
296
297    let pat_url = dc_pat_url(host);
298    let config_status = if path.exists() {
299        "exists — run `jira config show` to see current values"
300    } else {
301        "not found — create it"
302    };
303
304    if out.json {
305        out.print_data(
306            &serde_json::to_string_pretty(&serde_json::json!({
307                "configPath": path,
308                "pathResolution": path_resolution,
309                "configExists": path.exists(),
310                "tokenInstructions": CLOUD_TOKEN_URL,
311                "dcPatInstructions": pat_url,
312                "recommendedPermissions": permission_advice,
313                "example": example,
314            }))
315            .expect("failed to serialize JSON"),
316        );
317        return;
318    }
319
320    let cloud_link = crate::output::hyperlink(CLOUD_TOKEN_URL);
321    let pat_link = crate::output::hyperlink(&pat_url);
322
323    out.print_data(&format!(
324        "\
325Config file: {path_display} ({config_status})
326
327── Jira Cloud ────────────────────────────────────────────────────────────────
328
329[default]
330host  = \"mycompany.atlassian.net\"
331email = \"me@example.com\"
332token = \"your-api-token\"
333
334  {cloud_link}
335
336── Jira Data Center / Server ─────────────────────────────────────────────────
337
338[profiles.dc]
339host        = \"jira.mycompany.com\"
340token       = \"your-personal-access-token\"
341auth_type   = \"pat\"
342api_version = 2
343
344  {pat_link}
345
346Use --profile dc to switch:  jira --profile dc <command>
347                         or: JIRA_PROFILE=dc jira <command>
348
349── Security ──────────────────────────────────────────────────────────────────
350
351{permission_advice}",
352        path_display = path.display(),
353    ));
354}
355
356const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
357
358/// Build the Personal Access Token creation URL for a Jira DC/Server instance.
359///
360/// When `host` is known the full URL is returned so the user can click it directly.
361/// When unknown a placeholder template is returned.
362fn dc_pat_url(host: Option<&str>) -> String {
363    match host {
364        Some(h) => {
365            let base = if h.starts_with("http://") || h.starts_with("https://") {
366                h.trim_end_matches('/').to_string()
367            } else {
368                format!("https://{}", h.trim_end_matches('/'))
369            };
370            format!("{base}{PAT_PATH}")
371        }
372        None => format!("http://<your-host>{PAT_PATH}"),
373    }
374}
375
376/// Mask a token for display, showing only the last 4 characters.
377///
378/// Atlassian tokens begin with a predictable prefix, so showing the
379/// start provides no meaningful identification — the end is more useful.
380fn mask_token(token: &str) -> String {
381    let n = token.chars().count();
382    if n > 4 {
383        let suffix: String = token.chars().skip(n - 4).collect();
384        format!("***{suffix}")
385    } else {
386        "***".into()
387    }
388}
389
390fn env_var(name: &str) -> Option<String> {
391    std::env::var(name)
392        .ok()
393        .and_then(|value| normalize_value(Some(value)))
394}
395
396fn normalize_value(value: Option<String>) -> Option<String> {
397    value.and_then(|value| {
398        let trimmed = value.trim();
399        if trimmed.is_empty() {
400            None
401        } else {
402            Some(trimmed.to_string())
403        }
404    })
405}
406
407fn normalize_str(value: Option<&str>) -> Option<&str> {
408    value.and_then(|value| {
409        let trimmed = value.trim();
410        if trimmed.is_empty() {
411            None
412        } else {
413            Some(trimmed)
414        }
415    })
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
422    use tempfile::TempDir;
423
424    #[test]
425    fn mask_token_long() {
426        let masked = mask_token("ATATxxx1234abcd");
427        assert!(masked.starts_with("***"));
428        assert!(masked.ends_with("abcd"));
429    }
430
431    #[test]
432    fn mask_token_short() {
433        assert_eq!(mask_token("abc"), "***");
434    }
435
436    #[test]
437    fn mask_token_unicode_safe() {
438        // Ensure char-based indexing doesn't panic on multi-byte chars
439        let token = "token-日本語-end";
440        let result = mask_token(token);
441        assert!(result.starts_with("***"));
442    }
443
444    #[test]
445    #[cfg(not(target_os = "windows"))]
446    fn config_path_prefers_xdg_config_home() {
447        let _env = ProcessEnvLock::acquire().unwrap();
448        let dir = TempDir::new().unwrap();
449        let _config_dir = set_config_dir_env(dir.path());
450
451        assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
452    }
453
454    #[test]
455    fn load_ignores_blank_env_vars_and_falls_back_to_file() {
456        let _env = ProcessEnvLock::acquire().unwrap();
457        let dir = TempDir::new().unwrap();
458        write_config(
459            dir.path(),
460            r#"
461[default]
462host = "work.atlassian.net"
463email = "me@example.com"
464token = "secret-token"
465"#,
466        )
467        .unwrap();
468
469        let _config_dir = set_config_dir_env(dir.path());
470        let _host = EnvVarGuard::set("JIRA_HOST", "   ");
471        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
472        let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
473        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
474
475        let cfg = Config::load(None, None, None).unwrap();
476        assert_eq!(cfg.host, "work.atlassian.net");
477        assert_eq!(cfg.email, "me@example.com");
478        assert_eq!(cfg.token, "secret-token");
479    }
480
481    #[test]
482    fn load_accepts_documented_default_section() {
483        let _env = ProcessEnvLock::acquire().unwrap();
484        let dir = TempDir::new().unwrap();
485        write_config(
486            dir.path(),
487            r#"
488[default]
489host = "example.atlassian.net"
490email = "me@example.com"
491token = "secret-token"
492"#,
493        )
494        .unwrap();
495
496        let _config_dir = set_config_dir_env(dir.path());
497        let _host = EnvVarGuard::unset("JIRA_HOST");
498        let _email = EnvVarGuard::unset("JIRA_EMAIL");
499        let _token = EnvVarGuard::unset("JIRA_TOKEN");
500        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
501
502        let cfg = Config::load(None, None, None).unwrap();
503        assert_eq!(cfg.host, "example.atlassian.net");
504        assert_eq!(cfg.email, "me@example.com");
505        assert_eq!(cfg.token, "secret-token");
506    }
507
508    #[test]
509    fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
510        let _env = ProcessEnvLock::acquire().unwrap();
511        let dir = TempDir::new().unwrap();
512        let _config_dir = set_config_dir_env(dir.path());
513        let _host = EnvVarGuard::set("JIRA_HOST", "");
514        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
515        let _token = EnvVarGuard::set("JIRA_TOKEN", "");
516        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
517
518        let err = Config::load(None, None, None).unwrap_err();
519        assert!(matches!(err, ApiError::InvalidInput(_)));
520        assert!(err.to_string().contains("No Jira host configured"));
521    }
522
523    #[test]
524    fn permission_guidance_matches_platform() {
525        let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
526
527        #[cfg(target_os = "windows")]
528        assert!(guidance.contains("AppData"));
529
530        #[cfg(not(target_os = "windows"))]
531        assert!(guidance.starts_with("chmod 600 "));
532    }
533}