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