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
534    // ── Priority: CLI > env > file ─────────────────────────────────────────────
535
536    #[test]
537    fn load_env_host_overrides_file() {
538        let _env = ProcessEnvLock::acquire().unwrap();
539        let dir = TempDir::new().unwrap();
540        write_config(
541            dir.path(),
542            r#"
543[default]
544host = "file.atlassian.net"
545email = "me@example.com"
546token = "tok"
547"#,
548        )
549        .unwrap();
550
551        let _config_dir = set_config_dir_env(dir.path());
552        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
553        let _email = EnvVarGuard::unset("JIRA_EMAIL");
554        let _token = EnvVarGuard::unset("JIRA_TOKEN");
555        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
556
557        let cfg = Config::load(None, None, None).unwrap();
558        assert_eq!(cfg.host, "env.atlassian.net");
559    }
560
561    #[test]
562    fn load_cli_host_arg_overrides_env_and_file() {
563        let _env = ProcessEnvLock::acquire().unwrap();
564        let dir = TempDir::new().unwrap();
565        write_config(
566            dir.path(),
567            r#"
568[default]
569host = "file.atlassian.net"
570email = "me@example.com"
571token = "tok"
572"#,
573        )
574        .unwrap();
575
576        let _config_dir = set_config_dir_env(dir.path());
577        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
578        let _email = EnvVarGuard::unset("JIRA_EMAIL");
579        let _token = EnvVarGuard::unset("JIRA_TOKEN");
580        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
581
582        let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
583        assert_eq!(cfg.host, "cli.atlassian.net");
584    }
585
586    // ── Error cases ────────────────────────────────────────────────────────────
587
588    #[test]
589    fn load_missing_token_returns_error() {
590        let _env = ProcessEnvLock::acquire().unwrap();
591        let dir = TempDir::new().unwrap();
592        let _config_dir = set_config_dir_env(dir.path());
593        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
594        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
595        let _token = EnvVarGuard::unset("JIRA_TOKEN");
596        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
597
598        let err = Config::load(None, None, None).unwrap_err();
599        assert!(matches!(err, ApiError::InvalidInput(_)));
600        assert!(err.to_string().contains("No API token"));
601    }
602
603    #[test]
604    fn load_missing_email_for_basic_auth_returns_error() {
605        let _env = ProcessEnvLock::acquire().unwrap();
606        let dir = TempDir::new().unwrap();
607        let _config_dir = set_config_dir_env(dir.path());
608        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
609        let _email = EnvVarGuard::unset("JIRA_EMAIL");
610        let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
611        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
612        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
613
614        let err = Config::load(None, None, None).unwrap_err();
615        assert!(matches!(err, ApiError::InvalidInput(_)));
616        assert!(err.to_string().contains("No email configured"));
617    }
618
619    #[test]
620    fn load_invalid_toml_returns_error() {
621        let _env = ProcessEnvLock::acquire().unwrap();
622        let dir = TempDir::new().unwrap();
623        write_config(dir.path(), "host = [invalid toml").unwrap();
624
625        let _config_dir = set_config_dir_env(dir.path());
626        let _host = EnvVarGuard::unset("JIRA_HOST");
627        let _token = EnvVarGuard::unset("JIRA_TOKEN");
628        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
629
630        let err = Config::load(None, None, None).unwrap_err();
631        assert!(matches!(err, ApiError::Other(_)));
632        assert!(err.to_string().contains("parse"));
633    }
634
635    // ── Auth type ──────────────────────────────────────────────────────────────
636
637    #[test]
638    fn load_pat_auth_does_not_require_email() {
639        let _env = ProcessEnvLock::acquire().unwrap();
640        let dir = TempDir::new().unwrap();
641        write_config(
642            dir.path(),
643            r#"
644[default]
645host = "jira.corp.com"
646token = "my-pat-token"
647auth_type = "pat"
648api_version = 2
649"#,
650        )
651        .unwrap();
652
653        let _config_dir = set_config_dir_env(dir.path());
654        let _host = EnvVarGuard::unset("JIRA_HOST");
655        let _email = EnvVarGuard::unset("JIRA_EMAIL");
656        let _token = EnvVarGuard::unset("JIRA_TOKEN");
657        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
658        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
659
660        let cfg = Config::load(None, None, None).unwrap();
661        assert_eq!(cfg.auth_type, AuthType::Pat);
662        assert_eq!(cfg.api_version, 2);
663        assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
664    }
665
666    #[test]
667    fn load_jira_auth_type_env_pat_overrides_basic() {
668        let _env = ProcessEnvLock::acquire().unwrap();
669        let dir = TempDir::new().unwrap();
670        write_config(
671            dir.path(),
672            r#"
673[default]
674host = "jira.corp.com"
675email = "me@example.com"
676token = "tok"
677auth_type = "basic"
678"#,
679        )
680        .unwrap();
681
682        let _config_dir = set_config_dir_env(dir.path());
683        let _host = EnvVarGuard::unset("JIRA_HOST");
684        let _email = EnvVarGuard::unset("JIRA_EMAIL");
685        let _token = EnvVarGuard::unset("JIRA_TOKEN");
686        let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
687        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
688
689        let cfg = Config::load(None, None, None).unwrap();
690        assert_eq!(cfg.auth_type, AuthType::Pat);
691    }
692
693    #[test]
694    fn load_jira_api_version_env_overrides_default() {
695        let _env = ProcessEnvLock::acquire().unwrap();
696        let dir = TempDir::new().unwrap();
697        let _config_dir = set_config_dir_env(dir.path());
698        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
699        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
700        let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
701        let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
702        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
703        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
704
705        let cfg = Config::load(None, None, None).unwrap();
706        assert_eq!(cfg.api_version, 2);
707    }
708
709    // ── Profile selection ──────────────────────────────────────────────────────
710
711    #[test]
712    fn load_profile_arg_selects_named_section() {
713        let _env = ProcessEnvLock::acquire().unwrap();
714        let dir = TempDir::new().unwrap();
715        write_config(
716            dir.path(),
717            r#"
718[default]
719host = "default.atlassian.net"
720email = "default@example.com"
721token = "default-tok"
722
723[profiles.work]
724host = "work.atlassian.net"
725email = "me@work.com"
726token = "work-tok"
727"#,
728        )
729        .unwrap();
730
731        let _config_dir = set_config_dir_env(dir.path());
732        let _host = EnvVarGuard::unset("JIRA_HOST");
733        let _email = EnvVarGuard::unset("JIRA_EMAIL");
734        let _token = EnvVarGuard::unset("JIRA_TOKEN");
735        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
736
737        let cfg = Config::load(None, None, Some("work".into())).unwrap();
738        assert_eq!(cfg.host, "work.atlassian.net");
739        assert_eq!(cfg.email, "me@work.com");
740        assert_eq!(cfg.token, "work-tok");
741    }
742
743    #[test]
744    fn load_jira_profile_env_selects_named_section() {
745        let _env = ProcessEnvLock::acquire().unwrap();
746        let dir = TempDir::new().unwrap();
747        write_config(
748            dir.path(),
749            r#"
750[default]
751host = "default.atlassian.net"
752email = "default@example.com"
753token = "default-tok"
754
755[profiles.staging]
756host = "staging.atlassian.net"
757email = "me@staging.com"
758token = "staging-tok"
759"#,
760        )
761        .unwrap();
762
763        let _config_dir = set_config_dir_env(dir.path());
764        let _host = EnvVarGuard::unset("JIRA_HOST");
765        let _email = EnvVarGuard::unset("JIRA_EMAIL");
766        let _token = EnvVarGuard::unset("JIRA_TOKEN");
767        let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
768
769        let cfg = Config::load(None, None, None).unwrap();
770        assert_eq!(cfg.host, "staging.atlassian.net");
771    }
772
773    #[test]
774    fn load_unknown_profile_returns_descriptive_error() {
775        let _env = ProcessEnvLock::acquire().unwrap();
776        let dir = TempDir::new().unwrap();
777        write_config(
778            dir.path(),
779            r#"
780[profiles.alpha]
781host = "alpha.atlassian.net"
782email = "me@alpha.com"
783token = "alpha-tok"
784"#,
785        )
786        .unwrap();
787
788        let _config_dir = set_config_dir_env(dir.path());
789        let _host = EnvVarGuard::unset("JIRA_HOST");
790        let _token = EnvVarGuard::unset("JIRA_TOKEN");
791        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
792
793        let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
794        assert!(matches!(err, ApiError::Other(_)));
795        let msg = err.to_string();
796        assert!(
797            msg.contains("nonexistent"),
798            "error should name the bad profile"
799        );
800        assert!(
801            msg.contains("alpha"),
802            "error should list available profiles"
803        );
804    }
805
806    // ── config::show ───────────────────────────────────────────────────────────
807
808    #[test]
809    fn show_json_output_includes_host_and_masked_token() {
810        let _env = ProcessEnvLock::acquire().unwrap();
811        let dir = TempDir::new().unwrap();
812        write_config(
813            dir.path(),
814            r#"
815[default]
816host = "show-test.atlassian.net"
817email = "me@example.com"
818token = "supersecrettoken"
819"#,
820        )
821        .unwrap();
822
823        let _config_dir = set_config_dir_env(dir.path());
824        let _host = EnvVarGuard::unset("JIRA_HOST");
825        let _email = EnvVarGuard::unset("JIRA_EMAIL");
826        let _token = EnvVarGuard::unset("JIRA_TOKEN");
827        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
828
829        let out = crate::output::OutputConfig::new(true, true);
830        // Must not error and must produce no error output
831        show(&out, None, None, None).unwrap();
832    }
833
834    #[test]
835    fn show_text_output_renders_without_error() {
836        let _env = ProcessEnvLock::acquire().unwrap();
837        let dir = TempDir::new().unwrap();
838        write_config(
839            dir.path(),
840            r#"
841[default]
842host = "show-test.atlassian.net"
843email = "me@example.com"
844token = "supersecrettoken"
845"#,
846        )
847        .unwrap();
848
849        let _config_dir = set_config_dir_env(dir.path());
850        let _host = EnvVarGuard::unset("JIRA_HOST");
851        let _email = EnvVarGuard::unset("JIRA_EMAIL");
852        let _token = EnvVarGuard::unset("JIRA_TOKEN");
853        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
854
855        let out = crate::output::OutputConfig::new(false, true);
856        show(&out, None, None, None).unwrap();
857    }
858
859    // ── config::init ───────────────────────────────────────────────────────────
860
861    #[test]
862    fn init_json_output_includes_example_and_paths() {
863        let out = crate::output::OutputConfig::new(true, true);
864        // No env or config needed — init() never loads credentials
865        init(&out, Some("jira.corp.com"));
866    }
867
868    #[test]
869    fn init_text_output_renders_without_error() {
870        let out = crate::output::OutputConfig::new(false, true);
871        init(&out, None);
872    }
873
874    // ── dc_pat_url ─────────────────────────────────────────────────────────────
875
876    #[test]
877    fn dc_pat_url_without_host_returns_placeholder() {
878        let url = dc_pat_url(None);
879        assert!(url.starts_with("http://<your-host>"));
880        assert!(url.contains(PAT_PATH));
881    }
882
883    #[test]
884    fn dc_pat_url_bare_host_adds_https_scheme() {
885        let url = dc_pat_url(Some("jira.corp.com"));
886        assert!(url.starts_with("https://jira.corp.com"));
887        assert!(url.contains(PAT_PATH));
888    }
889
890    #[test]
891    fn dc_pat_url_host_with_https_scheme_is_preserved() {
892        let url = dc_pat_url(Some("https://jira.corp.com/"));
893        assert!(url.starts_with("https://jira.corp.com"));
894        assert!(!url.contains("https://https://"));
895        assert!(url.contains(PAT_PATH));
896    }
897
898    #[test]
899    fn dc_pat_url_host_with_http_scheme_is_preserved() {
900        let url = dc_pat_url(Some("http://localhost:8080"));
901        assert!(url.starts_with("http://localhost:8080"));
902        assert!(url.contains(PAT_PATH));
903    }
904}