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/// Interactively set up the config file, or print JSON instructions when `--json` is used.
264///
265/// In JSON mode the function prints a machine-readable instructions object and returns.
266/// In an interactive terminal it prompts for Jira type, host, credentials, and profile
267/// name, verifies the credentials against the API, then writes (or updates)
268/// `~/.config/jira/config.toml`.
269pub async fn init(out: &OutputConfig, host: Option<&str>) {
270    if out.json {
271        init_json(out, host);
272        return;
273    }
274
275    use std::io::IsTerminal;
276    if !std::io::stdin().is_terminal() {
277        out.print_message(
278            "Run `jira init` in an interactive terminal to configure credentials, \
279             or use `jira init --json` for setup instructions.",
280        );
281        return;
282    }
283
284    if let Err(e) = init_interactive(host).await {
285        eprintln!("{} {e}", sym_fail());
286        std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
287    }
288}
289
290fn init_json(out: &OutputConfig, host: Option<&str>) {
291    let path = config_path();
292    let path_resolution = schema_config_path_description();
293    let permission_advice = recommended_permissions(&path);
294    let example = serde_json::json!({
295        "default": {
296            "host": "mycompany.atlassian.net",
297            "email": "me@example.com",
298            "token": "your-api-token",
299            "auth_type": "basic",
300            "api_version": 3,
301        },
302        "profiles": {
303            "work": {
304                "host": "work.atlassian.net",
305                "email": "me@work.com",
306                "token": "work-token",
307            },
308            "datacenter": {
309                "host": "jira.mycompany.com",
310                "token": "your-personal-access-token",
311                "auth_type": "pat",
312                "api_version": 2,
313            }
314        }
315    });
316
317    const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
318    let pat_url = dc_pat_url(host);
319
320    out.print_data(
321        &serde_json::to_string_pretty(&serde_json::json!({
322            "configPath": path,
323            "pathResolution": path_resolution,
324            "configExists": path.exists(),
325            "tokenInstructions": CLOUD_TOKEN_URL,
326            "dcPatInstructions": pat_url,
327            "recommendedPermissions": permission_advice,
328            "example": example,
329        }))
330        .expect("failed to serialize JSON"),
331    );
332}
333
334async fn init_interactive(prefill_host: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
335    let sep = sym_dim("──────────────");
336    eprintln!("Jira CLI Setup");
337    eprintln!("{sep}");
338
339    let path = config_path();
340
341    // Decide what to do: first run, update an existing profile, or add a new one.
342    //
343    // `target_name` holds the profile name to write:
344    //   Some(name) — already known (first run → "default"; update → chosen name)
345    //   None       — "add new" path, ask for name after credentials
346    let (target_name, existing): (Option<String>, Option<ProfileConfig>) = if path.exists() {
347        let profiles = list_profile_names(&path)?;
348
349        // Show the config path and each profile with its host so the user knows
350        // what exists before deciding whether to update or add.
351        eprintln!();
352        eprintln!(
353            "  {} {}",
354            sym_dim("Config:"),
355            sym_dim(&path.display().to_string())
356        );
357        eprintln!();
358        eprintln!("  {}:", sym_dim("Profiles"));
359        for name in &profiles {
360            let host = read_raw_profile(&path, name)
361                .ok()
362                .and_then(|p| p.host)
363                .unwrap_or_default();
364            eprintln!("    {} {}  {}", sym_dim("•"), name, sym_dim(&host));
365        }
366        eprintln!();
367
368        let action = prompt("Action", "[update/add]", Some("update"))?;
369        eprintln!();
370
371        if !action.trim().eq_ignore_ascii_case("add") {
372            let default = profiles.first().map(String::as_str).unwrap_or("default");
373            let raw = if profiles.len() > 1 {
374                prompt("Profile", "", Some(default))?
375            } else {
376                default.to_owned()
377            };
378            let name = if raw.trim().is_empty() {
379                default.to_owned()
380            } else {
381                raw.trim().to_owned()
382            };
383            let cfg = read_raw_profile(&path, &name)?;
384            if profiles.len() > 1 {
385                eprintln!();
386            }
387            (Some(name), Some(cfg))
388        } else {
389            (None, None)
390        }
391    } else {
392        // First run: silently use "default", no need to ask.
393        eprintln!();
394        (Some("default".to_owned()), None)
395    };
396
397    // Instance type — derive from existing config, or ask.
398    let is_cloud = if let Some(ref p) = existing {
399        p.auth_type.as_deref() != Some("pat")
400    } else {
401        let t = prompt("Type", sym_dim("[cloud/dc]").as_str(), Some("cloud"))?;
402        eprintln!();
403        !t.trim().eq_ignore_ascii_case("dc")
404    };
405
406    // Host
407    let host = if is_cloud {
408        let default_sub = existing
409            .as_ref()
410            .and_then(|p| p.host.clone())
411            .as_deref()
412            .or(prefill_host)
413            .map(|h| h.trim_end_matches(".atlassian.net").to_owned());
414        let raw = prompt_required("Subdomain", "", default_sub.as_deref())?;
415        let sub = raw.trim().trim_end_matches(".atlassian.net");
416        format!("{sub}.atlassian.net")
417    } else {
418        let default = existing
419            .as_ref()
420            .and_then(|p| p.host.clone())
421            .or_else(|| prefill_host.map(str::to_owned));
422        prompt_required("Host", "", default.as_deref())?
423    };
424
425    // Credentials
426    let (email, token, auth_type, api_version): (Option<String>, String, &str, u8) = if is_cloud {
427        const CLOUD_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
428        let default_email = existing.as_ref().and_then(|p| p.email.clone());
429        let email = prompt_required("Email", "", default_email.as_deref())?;
430        eprintln!("  {}", sym_dim(&format!("→ {CLOUD_URL}")));
431        let token_hint = if existing.as_ref().and_then(|p| p.token.as_ref()).is_some() {
432            "(Enter to keep)"
433        } else {
434            ""
435        };
436        let raw = prompt("Token", token_hint, None)?;
437        let token = if raw.trim().is_empty() {
438            existing
439                .as_ref()
440                .and_then(|p| p.token.clone())
441                .ok_or("No existing token — please enter a token.")?
442        } else {
443            raw
444        };
445        (Some(email), token, "basic", 3)
446    } else {
447        let pat_url = dc_pat_url(Some(&host));
448        eprintln!("  {}", sym_dim(&format!("→ {pat_url}")));
449        let token_hint = if existing.as_ref().and_then(|p| p.token.as_ref()).is_some() {
450            "(Enter to keep)"
451        } else {
452            ""
453        };
454        let raw = prompt("Token", token_hint, None)?;
455        let token = if raw.trim().is_empty() {
456            existing
457                .as_ref()
458                .and_then(|p| p.token.clone())
459                .ok_or("No existing token — please enter a token.")?
460        } else {
461            raw
462        };
463        let default_ver = existing
464            .as_ref()
465            .and_then(|p| p.api_version.map(|v| v.to_string()))
466            .unwrap_or_else(|| "2".to_owned());
467        let ver_str = prompt("API version", "", Some(&default_ver))?;
468        let api_version: u8 = ver_str.trim().parse().unwrap_or(2);
469        (None, token, "pat", api_version)
470    };
471
472    // Verify credentials against the API before writing anything.
473    use std::io::Write;
474    eprintln!();
475    eprint!("  Verifying credentials...");
476    std::io::stderr().flush().ok();
477
478    let auth_type_enum = if auth_type == "pat" {
479        AuthType::Pat
480    } else {
481        AuthType::Basic
482    };
483
484    let verified = match crate::api::client::JiraClient::new(
485        &host,
486        email.as_deref().unwrap_or(""),
487        &token,
488        auth_type_enum,
489        api_version,
490    ) {
491        Err(e) => {
492            eprintln!(" {} {e}", sym_fail());
493            return Err(e.into());
494        }
495        Ok(client) => match client.get_myself().await {
496            Ok(myself) => {
497                eprintln!(" {} Authenticated as {}", sym_ok(), myself.display_name);
498                true
499            }
500            Err(e) => {
501                eprintln!(" {} {e}", sym_fail());
502                eprintln!();
503                let save = prompt("Save config anyway?", sym_dim("[y/N]").as_str(), Some("n"))?;
504                save.trim().eq_ignore_ascii_case("y")
505            }
506        },
507    };
508
509    if !verified {
510        eprintln!();
511        eprintln!("{sep}");
512        return Ok(());
513    }
514
515    // Profile name — ask only when adding a new named profile.
516    let profile_name = match target_name {
517        Some(name) => name,
518        None => {
519            eprintln!();
520            let raw = prompt_required("Profile name", "", Some("default"))?;
521            if raw.trim().is_empty() {
522                "default".to_owned()
523            } else {
524                raw.trim().to_owned()
525            }
526        }
527    };
528
529    // Write config
530    write_profile_to_config(
531        &path,
532        &profile_name,
533        &host,
534        email.as_deref(),
535        &token,
536        auth_type,
537        api_version,
538    )?;
539
540    #[cfg(unix)]
541    {
542        use std::os::unix::fs::PermissionsExt;
543        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
544    }
545
546    eprintln!();
547    eprintln!("  {} Config written to {}", sym_ok(), path.display());
548    eprintln!("{sep}");
549    if profile_name == "default" {
550        eprintln!("  Run: jira projects list");
551    } else {
552        eprintln!("  Run: jira --profile {profile_name} projects list");
553    }
554    eprintln!();
555
556    Ok(())
557}
558
559/// List all profile names present in the config file (default first, then named profiles).
560fn list_profile_names(path: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
561    let content = std::fs::read_to_string(path)?;
562    let doc: toml::Value = toml::from_str(&content)?;
563    let table = doc.as_table().ok_or("config is not a TOML table")?;
564
565    let mut names = Vec::new();
566    if table.contains_key("default") {
567        names.push("default".to_owned());
568    }
569    if let Some(profiles) = table.get("profiles").and_then(toml::Value::as_table) {
570        for name in profiles.keys() {
571            names.push(name.clone());
572        }
573    }
574    Ok(names)
575}
576
577/// Read a single profile's raw values from the config file for use as pre-fill defaults.
578fn read_raw_profile(
579    path: &std::path::Path,
580    name: &str,
581) -> Result<ProfileConfig, Box<dyn std::error::Error>> {
582    let content = std::fs::read_to_string(path)?;
583    let raw: RawConfig = toml::from_str(&content)?;
584    if name == "default" {
585        Ok(raw.default_profile())
586    } else {
587        Ok(raw.profiles.get(name).cloned().unwrap_or_default())
588    }
589}
590
591/// Print `? Label  hint [default]: ` and read a line from stdin.
592///
593/// `hint` is shown dimmed between the label and the default bracket; pass `""` to omit it.
594/// Returns the default string when the user presses Enter without typing.
595fn prompt(label: &str, hint: &str, default: Option<&str>) -> Result<String, std::io::Error> {
596    use std::io::{self, Write};
597    let hint_part = if hint.is_empty() {
598        String::new()
599    } else {
600        format!("  {hint}")
601    };
602    let default_part = match default {
603        Some(d) if !d.is_empty() => format!(" [{d}]"),
604        _ => String::new(),
605    };
606    eprint!("{} {label}{hint_part}{default_part}: ", sym_q());
607    io::stderr().flush()?;
608    let mut buf = String::new();
609    io::stdin().read_line(&mut buf)?;
610    let trimmed = buf.trim().to_owned();
611    if trimmed.is_empty() {
612        Ok(default.unwrap_or("").to_owned())
613    } else {
614        Ok(trimmed)
615    }
616}
617
618/// Like `prompt` but re-prompts until the user provides a non-empty value.
619fn prompt_required(
620    label: &str,
621    hint: &str,
622    default: Option<&str>,
623) -> Result<String, std::io::Error> {
624    loop {
625        let value = prompt(label, hint, default)?;
626        if !value.trim().is_empty() {
627            return Ok(value);
628        }
629        eprintln!("  {} {label} is required.", sym_fail());
630    }
631}
632
633// ── Color / symbol helpers ──────────────────────────────────────────────────
634
635fn sym_q() -> String {
636    if crate::output::use_color() {
637        use owo_colors::OwoColorize;
638        "?".green().bold().to_string()
639    } else {
640        "?".to_owned()
641    }
642}
643
644fn sym_ok() -> String {
645    if crate::output::use_color() {
646        use owo_colors::OwoColorize;
647        "✔".green().to_string()
648    } else {
649        "✔".to_owned()
650    }
651}
652
653fn sym_fail() -> String {
654    if crate::output::use_color() {
655        use owo_colors::OwoColorize;
656        "✖".red().to_string()
657    } else {
658        "✖".to_owned()
659    }
660}
661
662fn sym_dim(s: &str) -> String {
663    if crate::output::use_color() {
664        use owo_colors::OwoColorize;
665        s.dimmed().to_string()
666    } else {
667        s.to_owned()
668    }
669}
670
671/// Write or update a single profile section in the config file.
672///
673/// If the file already exists its other sections are preserved; only the target
674/// profile section is created or replaced. The parent directory is created if needed.
675fn write_profile_to_config(
676    path: &std::path::Path,
677    profile_name: &str,
678    host: &str,
679    email: Option<&str>,
680    token: &str,
681    auth_type: &str,
682    api_version: u8,
683) -> Result<(), Box<dyn std::error::Error>> {
684    let existing = if path.exists() {
685        std::fs::read_to_string(path)?
686    } else {
687        String::new()
688    };
689
690    let mut doc: toml::Value = if existing.trim().is_empty() {
691        toml::Value::Table(toml::map::Map::new())
692    } else {
693        toml::from_str(&existing)?
694    };
695
696    let root = doc.as_table_mut().expect("config is a TOML table");
697
698    let mut section = toml::map::Map::new();
699    section.insert("host".to_owned(), toml::Value::String(host.to_owned()));
700    if let Some(e) = email {
701        section.insert("email".to_owned(), toml::Value::String(e.to_owned()));
702    }
703    section.insert("token".to_owned(), toml::Value::String(token.to_owned()));
704    if auth_type != "basic" {
705        section.insert(
706            "auth_type".to_owned(),
707            toml::Value::String(auth_type.to_owned()),
708        );
709        section.insert(
710            "api_version".to_owned(),
711            toml::Value::Integer(i64::from(api_version)),
712        );
713    }
714
715    if profile_name == "default" {
716        root.insert("default".to_owned(), toml::Value::Table(section));
717    } else {
718        let profiles = root
719            .entry("profiles")
720            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
721        profiles
722            .as_table_mut()
723            .expect("profiles is a TOML table")
724            .insert(profile_name.to_owned(), toml::Value::Table(section));
725    }
726
727    if let Some(parent) = path.parent() {
728        std::fs::create_dir_all(parent)?;
729    }
730    std::fs::write(path, toml::to_string_pretty(&doc)?)?;
731
732    Ok(())
733}
734
735/// Remove a named profile from the config file.
736///
737/// The "default" profile is removed by deleting the `[default]` section. Named profiles
738/// are removed from the `[profiles]` table. Prints a success or error message; does not
739/// write to stdout so it is safe in JSON mode.
740pub fn remove_profile(profile_name: &str) {
741    let path = config_path();
742
743    if !path.exists() {
744        eprintln!("No config file found at {}", path.display());
745        std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
746    }
747
748    let result = (|| -> Result<(), Box<dyn std::error::Error>> {
749        let content = std::fs::read_to_string(&path)?;
750        let mut doc: toml::Value = toml::from_str(&content)?;
751        let root = doc.as_table_mut().ok_or("config is not a TOML table")?;
752
753        let removed = if profile_name == "default" {
754            root.remove("default").is_some()
755        } else {
756            root.get_mut("profiles")
757                .and_then(toml::Value::as_table_mut)
758                .and_then(|t| t.remove(profile_name))
759                .is_some()
760        };
761
762        if !removed {
763            return Err(format!("profile '{profile_name}' not found").into());
764        }
765
766        std::fs::write(&path, toml::to_string_pretty(&doc)?)?;
767        Ok(())
768    })();
769
770    match result {
771        Ok(()) => {
772            eprintln!("  {} Removed profile '{profile_name}'", sym_ok());
773        }
774        Err(e) => {
775            eprintln!("  {} {e}", sym_fail());
776            std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
777        }
778    }
779}
780
781const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
782
783/// Build the Personal Access Token creation URL for a Jira DC/Server instance.
784///
785/// When `host` is known the full URL is returned so the user can click it directly.
786/// When unknown a placeholder template is returned.
787fn dc_pat_url(host: Option<&str>) -> String {
788    match host {
789        Some(h) => {
790            let base = if h.starts_with("http://") || h.starts_with("https://") {
791                h.trim_end_matches('/').to_string()
792            } else {
793                format!("https://{}", h.trim_end_matches('/'))
794            };
795            format!("{base}{PAT_PATH}")
796        }
797        None => format!("http://<your-host>{PAT_PATH}"),
798    }
799}
800
801/// Mask a token for display, showing only the last 4 characters.
802///
803/// Atlassian tokens begin with a predictable prefix, so showing the
804/// start provides no meaningful identification — the end is more useful.
805fn mask_token(token: &str) -> String {
806    let n = token.chars().count();
807    if n > 4 {
808        let suffix: String = token.chars().skip(n - 4).collect();
809        format!("***{suffix}")
810    } else {
811        "***".into()
812    }
813}
814
815fn env_var(name: &str) -> Option<String> {
816    std::env::var(name)
817        .ok()
818        .and_then(|value| normalize_value(Some(value)))
819}
820
821fn normalize_value(value: Option<String>) -> Option<String> {
822    value.and_then(|value| {
823        let trimmed = value.trim();
824        if trimmed.is_empty() {
825            None
826        } else {
827            Some(trimmed.to_string())
828        }
829    })
830}
831
832fn normalize_str(value: Option<&str>) -> Option<&str> {
833    value.and_then(|value| {
834        let trimmed = value.trim();
835        if trimmed.is_empty() {
836            None
837        } else {
838            Some(trimmed)
839        }
840    })
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
847    use tempfile::TempDir;
848
849    #[test]
850    fn mask_token_long() {
851        let masked = mask_token("ATATxxx1234abcd");
852        assert!(masked.starts_with("***"));
853        assert!(masked.ends_with("abcd"));
854    }
855
856    #[test]
857    fn mask_token_short() {
858        assert_eq!(mask_token("abc"), "***");
859    }
860
861    #[test]
862    fn mask_token_unicode_safe() {
863        // Ensure char-based indexing doesn't panic on multi-byte chars
864        let token = "token-日本語-end";
865        let result = mask_token(token);
866        assert!(result.starts_with("***"));
867    }
868
869    #[test]
870    #[cfg(not(target_os = "windows"))]
871    fn config_path_prefers_xdg_config_home() {
872        let _env = ProcessEnvLock::acquire().unwrap();
873        let dir = TempDir::new().unwrap();
874        let _config_dir = set_config_dir_env(dir.path());
875
876        assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
877    }
878
879    #[test]
880    fn load_ignores_blank_env_vars_and_falls_back_to_file() {
881        let _env = ProcessEnvLock::acquire().unwrap();
882        let dir = TempDir::new().unwrap();
883        write_config(
884            dir.path(),
885            r#"
886[default]
887host = "work.atlassian.net"
888email = "me@example.com"
889token = "secret-token"
890"#,
891        )
892        .unwrap();
893
894        let _config_dir = set_config_dir_env(dir.path());
895        let _host = EnvVarGuard::set("JIRA_HOST", "   ");
896        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
897        let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
898        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
899
900        let cfg = Config::load(None, None, None).unwrap();
901        assert_eq!(cfg.host, "work.atlassian.net");
902        assert_eq!(cfg.email, "me@example.com");
903        assert_eq!(cfg.token, "secret-token");
904    }
905
906    #[test]
907    fn load_accepts_documented_default_section() {
908        let _env = ProcessEnvLock::acquire().unwrap();
909        let dir = TempDir::new().unwrap();
910        write_config(
911            dir.path(),
912            r#"
913[default]
914host = "example.atlassian.net"
915email = "me@example.com"
916token = "secret-token"
917"#,
918        )
919        .unwrap();
920
921        let _config_dir = set_config_dir_env(dir.path());
922        let _host = EnvVarGuard::unset("JIRA_HOST");
923        let _email = EnvVarGuard::unset("JIRA_EMAIL");
924        let _token = EnvVarGuard::unset("JIRA_TOKEN");
925        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
926
927        let cfg = Config::load(None, None, None).unwrap();
928        assert_eq!(cfg.host, "example.atlassian.net");
929        assert_eq!(cfg.email, "me@example.com");
930        assert_eq!(cfg.token, "secret-token");
931    }
932
933    #[test]
934    fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
935        let _env = ProcessEnvLock::acquire().unwrap();
936        let dir = TempDir::new().unwrap();
937        let _config_dir = set_config_dir_env(dir.path());
938        let _host = EnvVarGuard::set("JIRA_HOST", "");
939        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
940        let _token = EnvVarGuard::set("JIRA_TOKEN", "");
941        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
942
943        let err = Config::load(None, None, None).unwrap_err();
944        assert!(matches!(err, ApiError::InvalidInput(_)));
945        assert!(err.to_string().contains("No Jira host configured"));
946    }
947
948    #[test]
949    fn permission_guidance_matches_platform() {
950        let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
951
952        #[cfg(target_os = "windows")]
953        assert!(guidance.contains("AppData"));
954
955        #[cfg(not(target_os = "windows"))]
956        assert!(guidance.starts_with("chmod 600 "));
957    }
958
959    // ── Priority: CLI > env > file ─────────────────────────────────────────────
960
961    #[test]
962    fn load_env_host_overrides_file() {
963        let _env = ProcessEnvLock::acquire().unwrap();
964        let dir = TempDir::new().unwrap();
965        write_config(
966            dir.path(),
967            r#"
968[default]
969host = "file.atlassian.net"
970email = "me@example.com"
971token = "tok"
972"#,
973        )
974        .unwrap();
975
976        let _config_dir = set_config_dir_env(dir.path());
977        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
978        let _email = EnvVarGuard::unset("JIRA_EMAIL");
979        let _token = EnvVarGuard::unset("JIRA_TOKEN");
980        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
981
982        let cfg = Config::load(None, None, None).unwrap();
983        assert_eq!(cfg.host, "env.atlassian.net");
984    }
985
986    #[test]
987    fn load_cli_host_arg_overrides_env_and_file() {
988        let _env = ProcessEnvLock::acquire().unwrap();
989        let dir = TempDir::new().unwrap();
990        write_config(
991            dir.path(),
992            r#"
993[default]
994host = "file.atlassian.net"
995email = "me@example.com"
996token = "tok"
997"#,
998        )
999        .unwrap();
1000
1001        let _config_dir = set_config_dir_env(dir.path());
1002        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1003        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1004        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1005        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1006
1007        let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
1008        assert_eq!(cfg.host, "cli.atlassian.net");
1009    }
1010
1011    // ── Error cases ────────────────────────────────────────────────────────────
1012
1013    #[test]
1014    fn load_missing_token_returns_error() {
1015        let _env = ProcessEnvLock::acquire().unwrap();
1016        let dir = TempDir::new().unwrap();
1017        let _config_dir = set_config_dir_env(dir.path());
1018        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1019        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1020        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1021        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1022
1023        let err = Config::load(None, None, None).unwrap_err();
1024        assert!(matches!(err, ApiError::InvalidInput(_)));
1025        assert!(err.to_string().contains("No API token"));
1026    }
1027
1028    #[test]
1029    fn load_missing_email_for_basic_auth_returns_error() {
1030        let _env = ProcessEnvLock::acquire().unwrap();
1031        let dir = TempDir::new().unwrap();
1032        let _config_dir = set_config_dir_env(dir.path());
1033        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1034        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1035        let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
1036        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1037        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1038
1039        let err = Config::load(None, None, None).unwrap_err();
1040        assert!(matches!(err, ApiError::InvalidInput(_)));
1041        assert!(err.to_string().contains("No email configured"));
1042    }
1043
1044    #[test]
1045    fn load_invalid_toml_returns_error() {
1046        let _env = ProcessEnvLock::acquire().unwrap();
1047        let dir = TempDir::new().unwrap();
1048        write_config(dir.path(), "host = [invalid toml").unwrap();
1049
1050        let _config_dir = set_config_dir_env(dir.path());
1051        let _host = EnvVarGuard::unset("JIRA_HOST");
1052        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1053        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1054
1055        let err = Config::load(None, None, None).unwrap_err();
1056        assert!(matches!(err, ApiError::Other(_)));
1057        assert!(err.to_string().contains("parse"));
1058    }
1059
1060    // ── Auth type ──────────────────────────────────────────────────────────────
1061
1062    #[test]
1063    fn load_pat_auth_does_not_require_email() {
1064        let _env = ProcessEnvLock::acquire().unwrap();
1065        let dir = TempDir::new().unwrap();
1066        write_config(
1067            dir.path(),
1068            r#"
1069[default]
1070host = "jira.corp.com"
1071token = "my-pat-token"
1072auth_type = "pat"
1073api_version = 2
1074"#,
1075        )
1076        .unwrap();
1077
1078        let _config_dir = set_config_dir_env(dir.path());
1079        let _host = EnvVarGuard::unset("JIRA_HOST");
1080        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1081        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1082        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1083        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1084
1085        let cfg = Config::load(None, None, None).unwrap();
1086        assert_eq!(cfg.auth_type, AuthType::Pat);
1087        assert_eq!(cfg.api_version, 2);
1088        assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
1089    }
1090
1091    #[test]
1092    fn load_jira_auth_type_env_pat_overrides_basic() {
1093        let _env = ProcessEnvLock::acquire().unwrap();
1094        let dir = TempDir::new().unwrap();
1095        write_config(
1096            dir.path(),
1097            r#"
1098[default]
1099host = "jira.corp.com"
1100email = "me@example.com"
1101token = "tok"
1102auth_type = "basic"
1103"#,
1104        )
1105        .unwrap();
1106
1107        let _config_dir = set_config_dir_env(dir.path());
1108        let _host = EnvVarGuard::unset("JIRA_HOST");
1109        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1110        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1111        let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
1112        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1113
1114        let cfg = Config::load(None, None, None).unwrap();
1115        assert_eq!(cfg.auth_type, AuthType::Pat);
1116    }
1117
1118    #[test]
1119    fn load_jira_api_version_env_overrides_default() {
1120        let _env = ProcessEnvLock::acquire().unwrap();
1121        let dir = TempDir::new().unwrap();
1122        let _config_dir = set_config_dir_env(dir.path());
1123        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1124        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1125        let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
1126        let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
1127        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1128        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1129
1130        let cfg = Config::load(None, None, None).unwrap();
1131        assert_eq!(cfg.api_version, 2);
1132    }
1133
1134    // ── Profile selection ──────────────────────────────────────────────────────
1135
1136    #[test]
1137    fn load_profile_arg_selects_named_section() {
1138        let _env = ProcessEnvLock::acquire().unwrap();
1139        let dir = TempDir::new().unwrap();
1140        write_config(
1141            dir.path(),
1142            r#"
1143[default]
1144host = "default.atlassian.net"
1145email = "default@example.com"
1146token = "default-tok"
1147
1148[profiles.work]
1149host = "work.atlassian.net"
1150email = "me@work.com"
1151token = "work-tok"
1152"#,
1153        )
1154        .unwrap();
1155
1156        let _config_dir = set_config_dir_env(dir.path());
1157        let _host = EnvVarGuard::unset("JIRA_HOST");
1158        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1159        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1160        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1161
1162        let cfg = Config::load(None, None, Some("work".into())).unwrap();
1163        assert_eq!(cfg.host, "work.atlassian.net");
1164        assert_eq!(cfg.email, "me@work.com");
1165        assert_eq!(cfg.token, "work-tok");
1166    }
1167
1168    #[test]
1169    fn load_jira_profile_env_selects_named_section() {
1170        let _env = ProcessEnvLock::acquire().unwrap();
1171        let dir = TempDir::new().unwrap();
1172        write_config(
1173            dir.path(),
1174            r#"
1175[default]
1176host = "default.atlassian.net"
1177email = "default@example.com"
1178token = "default-tok"
1179
1180[profiles.staging]
1181host = "staging.atlassian.net"
1182email = "me@staging.com"
1183token = "staging-tok"
1184"#,
1185        )
1186        .unwrap();
1187
1188        let _config_dir = set_config_dir_env(dir.path());
1189        let _host = EnvVarGuard::unset("JIRA_HOST");
1190        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1191        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1192        let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
1193
1194        let cfg = Config::load(None, None, None).unwrap();
1195        assert_eq!(cfg.host, "staging.atlassian.net");
1196    }
1197
1198    #[test]
1199    fn load_unknown_profile_returns_descriptive_error() {
1200        let _env = ProcessEnvLock::acquire().unwrap();
1201        let dir = TempDir::new().unwrap();
1202        write_config(
1203            dir.path(),
1204            r#"
1205[profiles.alpha]
1206host = "alpha.atlassian.net"
1207email = "me@alpha.com"
1208token = "alpha-tok"
1209"#,
1210        )
1211        .unwrap();
1212
1213        let _config_dir = set_config_dir_env(dir.path());
1214        let _host = EnvVarGuard::unset("JIRA_HOST");
1215        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1216        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1217
1218        let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
1219        assert!(matches!(err, ApiError::Other(_)));
1220        let msg = err.to_string();
1221        assert!(
1222            msg.contains("nonexistent"),
1223            "error should name the bad profile"
1224        );
1225        assert!(
1226            msg.contains("alpha"),
1227            "error should list available profiles"
1228        );
1229    }
1230
1231    // ── config::show ───────────────────────────────────────────────────────────
1232
1233    #[test]
1234    fn show_json_output_includes_host_and_masked_token() {
1235        let _env = ProcessEnvLock::acquire().unwrap();
1236        let dir = TempDir::new().unwrap();
1237        write_config(
1238            dir.path(),
1239            r#"
1240[default]
1241host = "show-test.atlassian.net"
1242email = "me@example.com"
1243token = "supersecrettoken"
1244"#,
1245        )
1246        .unwrap();
1247
1248        let _config_dir = set_config_dir_env(dir.path());
1249        let _host = EnvVarGuard::unset("JIRA_HOST");
1250        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1251        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1252        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1253
1254        let out = crate::output::OutputConfig::new(true, true);
1255        // Must not error and must produce no error output
1256        show(&out, None, None, None).unwrap();
1257    }
1258
1259    #[test]
1260    fn show_text_output_renders_without_error() {
1261        let _env = ProcessEnvLock::acquire().unwrap();
1262        let dir = TempDir::new().unwrap();
1263        write_config(
1264            dir.path(),
1265            r#"
1266[default]
1267host = "show-test.atlassian.net"
1268email = "me@example.com"
1269token = "supersecrettoken"
1270"#,
1271        )
1272        .unwrap();
1273
1274        let _config_dir = set_config_dir_env(dir.path());
1275        let _host = EnvVarGuard::unset("JIRA_HOST");
1276        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1277        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1278        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1279
1280        let out = crate::output::OutputConfig::new(false, true);
1281        show(&out, None, None, None).unwrap();
1282    }
1283
1284    // ── config::init ───────────────────────────────────────────────────────────
1285
1286    #[tokio::test]
1287    async fn init_json_output_includes_example_and_paths() {
1288        let out = crate::output::OutputConfig::new(true, true);
1289        // No env or config needed — init() never loads credentials in JSON mode
1290        init(&out, Some("jira.corp.com")).await;
1291    }
1292
1293    // The text path of init() requires an interactive TTY; in test context stdin is
1294    // not a TTY so it prints a short message and returns without hanging.
1295    #[tokio::test]
1296    async fn init_non_interactive_prints_message_without_error() {
1297        let out = crate::output::OutputConfig {
1298            json: false,
1299            quiet: false,
1300        };
1301        // stdin is not a TTY in tests — must return immediately, not hang
1302        init(&out, None).await;
1303    }
1304
1305    #[test]
1306    fn write_profile_to_config_creates_default_profile() {
1307        let dir = TempDir::new().unwrap();
1308        let path = dir.path().join("jira").join("config.toml");
1309
1310        write_profile_to_config(
1311            &path,
1312            "default",
1313            "acme.atlassian.net",
1314            Some("me@acme.com"),
1315            "secret",
1316            "basic",
1317            3,
1318        )
1319        .unwrap();
1320
1321        let content = std::fs::read_to_string(&path).unwrap();
1322        assert!(content.contains("acme.atlassian.net"));
1323        assert!(content.contains("me@acme.com"));
1324        assert!(content.contains("secret"));
1325        // basic/v3 are defaults and should not add redundant keys
1326        assert!(!content.contains("auth_type"));
1327    }
1328
1329    #[test]
1330    fn write_profile_to_config_creates_named_pat_profile() {
1331        let dir = TempDir::new().unwrap();
1332        let path = dir.path().join("config.toml");
1333
1334        write_profile_to_config(&path, "dc", "jira.corp.com", None, "pattoken", "pat", 2).unwrap();
1335
1336        let content = std::fs::read_to_string(&path).unwrap();
1337        assert!(content.contains("[profiles.dc]"));
1338        assert!(content.contains("jira.corp.com"));
1339        assert!(content.contains("pattoken"));
1340        assert!(content.contains("auth_type"));
1341        assert!(content.contains("api_version"));
1342        assert!(!content.contains("email"));
1343    }
1344
1345    #[test]
1346    fn write_profile_to_config_preserves_other_profiles() {
1347        let dir = TempDir::new().unwrap();
1348        let path = dir.path().join("config.toml");
1349
1350        // Write initial config with a default profile
1351        std::fs::write(
1352            &path,
1353            "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
1354        )
1355        .unwrap();
1356
1357        // Add a second named profile without touching default
1358        write_profile_to_config(
1359            &path,
1360            "work",
1361            "work.atlassian.net",
1362            Some("w@work.com"),
1363            "tok2",
1364            "basic",
1365            3,
1366        )
1367        .unwrap();
1368
1369        let content = std::fs::read_to_string(&path).unwrap();
1370        assert!(
1371            content.contains("first.atlassian.net"),
1372            "default profile must be preserved"
1373        );
1374        assert!(
1375            content.contains("work.atlassian.net"),
1376            "new profile must be written"
1377        );
1378    }
1379
1380    // ── remove_profile ─────────────────────────────────────────────────────────
1381
1382    #[test]
1383    fn remove_profile_removes_default_section() {
1384        let _env = ProcessEnvLock::acquire().unwrap();
1385        let dir = TempDir::new().unwrap();
1386        let path = write_config(
1387            dir.path(),
1388            "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
1389        )
1390        .unwrap();
1391
1392        let _config_dir = set_config_dir_env(dir.path());
1393        remove_profile("default");
1394
1395        let content = std::fs::read_to_string(&path).unwrap();
1396        assert!(!content.contains("[default]"));
1397        assert!(!content.contains("acme.atlassian.net"));
1398    }
1399
1400    #[test]
1401    fn remove_profile_removes_named_profile_preserves_others() {
1402        let _env = ProcessEnvLock::acquire().unwrap();
1403        let dir = TempDir::new().unwrap();
1404        let path = write_config(
1405            dir.path(),
1406            "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
1407             [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
1408        )
1409        .unwrap();
1410
1411        let _config_dir = set_config_dir_env(dir.path());
1412        remove_profile("work");
1413
1414        let content = std::fs::read_to_string(&path).unwrap();
1415        assert!(
1416            !content.contains("work.atlassian.net"),
1417            "work profile must be gone"
1418        );
1419        assert!(
1420            content.contains("first.atlassian.net"),
1421            "default profile must be preserved"
1422        );
1423    }
1424
1425    #[test]
1426    fn remove_profile_last_named_profile_leaves_default_intact() {
1427        let _env = ProcessEnvLock::acquire().unwrap();
1428        let dir = TempDir::new().unwrap();
1429        let path = write_config(
1430            dir.path(),
1431            "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
1432             [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
1433        )
1434        .unwrap();
1435
1436        let _config_dir = set_config_dir_env(dir.path());
1437        remove_profile("staging");
1438
1439        let content = std::fs::read_to_string(&path).unwrap();
1440        assert!(
1441            !content.contains("staging.atlassian.net"),
1442            "staging must be gone"
1443        );
1444        assert!(
1445            content.contains("acme.atlassian.net"),
1446            "default must be preserved"
1447        );
1448    }
1449
1450    // ── dc_pat_url ─────────────────────────────────────────────────────────────
1451
1452    #[test]
1453    fn dc_pat_url_without_host_returns_placeholder() {
1454        let url = dc_pat_url(None);
1455        assert!(url.starts_with("http://<your-host>"));
1456        assert!(url.contains(PAT_PATH));
1457    }
1458
1459    #[test]
1460    fn dc_pat_url_bare_host_adds_https_scheme() {
1461        let url = dc_pat_url(Some("jira.corp.com"));
1462        assert!(url.starts_with("https://jira.corp.com"));
1463        assert!(url.contains(PAT_PATH));
1464    }
1465
1466    #[test]
1467    fn dc_pat_url_host_with_https_scheme_is_preserved() {
1468        let url = dc_pat_url(Some("https://jira.corp.com/"));
1469        assert!(url.starts_with("https://jira.corp.com"));
1470        assert!(!url.contains("https://https://"));
1471        assert!(url.contains(PAT_PATH));
1472    }
1473
1474    #[test]
1475    fn dc_pat_url_host_with_http_scheme_is_preserved() {
1476        let url = dc_pat_url(Some("http://localhost:8080"));
1477        assert!(url.starts_with("http://localhost:8080"));
1478        assert!(url.contains(PAT_PATH));
1479    }
1480}