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