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