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