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 credential_store: Option<String>,
16    pub cloud_id: Option<String>,
17    pub token_kind: Option<String>,
18    pub expires_at: Option<String>,
19    pub auth_type: Option<String>,
20    pub api_version: Option<u8>,
21    pub read_only: Option<bool>,
22}
23
24#[derive(Debug, Deserialize, Default)]
25struct RawConfig {
26    #[serde(default)]
27    active_profile: Option<String>,
28    #[serde(default)]
29    default: ProfileConfig,
30    #[serde(default)]
31    profiles: BTreeMap<String, ProfileConfig>,
32    host: Option<String>,
33    email: Option<String>,
34    token: Option<String>,
35    credential_store: Option<String>,
36    cloud_id: Option<String>,
37    token_kind: Option<String>,
38    expires_at: Option<String>,
39    auth_type: Option<String>,
40    api_version: Option<u8>,
41    read_only: Option<bool>,
42}
43
44impl RawConfig {
45    fn default_profile(&self) -> ProfileConfig {
46        ProfileConfig {
47            host: self.default.host.clone().or_else(|| self.host.clone()),
48            email: self.default.email.clone().or_else(|| self.email.clone()),
49            token: self.default.token.clone().or_else(|| self.token.clone()),
50            credential_store: self
51                .default
52                .credential_store
53                .clone()
54                .or_else(|| self.credential_store.clone()),
55            cloud_id: self
56                .default
57                .cloud_id
58                .clone()
59                .or_else(|| self.cloud_id.clone()),
60            token_kind: self
61                .default
62                .token_kind
63                .clone()
64                .or_else(|| self.token_kind.clone()),
65            expires_at: self
66                .default
67                .expires_at
68                .clone()
69                .or_else(|| self.expires_at.clone()),
70            auth_type: self
71                .default
72                .auth_type
73                .clone()
74                .or_else(|| self.auth_type.clone()),
75            api_version: self.default.api_version.or(self.api_version),
76            read_only: self.default.read_only.or(self.read_only),
77        }
78    }
79}
80
81/// Resolved credentials for a single profile.
82#[derive(Debug, Clone)]
83pub struct Config {
84    pub profile: String,
85    pub host: String,
86    pub email: String,
87    pub token: String,
88    pub auth_type: AuthType,
89    pub api_version: u8,
90    pub read_only: bool,
91    pub credential_store: String,
92    pub cloud_id: Option<String>,
93    pub token_kind: String,
94    pub expires_at: Option<String>,
95}
96
97impl Config {
98    /// Load config with priority: CLI args > env vars > config file.
99    ///
100    /// The API token must be supplied via the `JIRA_TOKEN` environment variable
101    /// or the config file - not via a CLI flag, to avoid leaking it in process
102    /// argument lists visible to other users.
103    pub fn load(
104        host_arg: Option<String>,
105        email_arg: Option<String>,
106        profile_arg: Option<String>,
107    ) -> Result<Self, ApiError> {
108        let (profile, file_profile) = load_file_profile(profile_arg.as_deref())?;
109
110        let host = normalize_value(host_arg)
111            .or_else(|| env_var("JIRA_HOST"))
112            .or_else(|| normalize_value(file_profile.host))
113            .ok_or_else(|| {
114                ApiError::InvalidInput(
115                    "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
116                )
117            })?;
118
119        let env_token = env_var("JIRA_TOKEN");
120        let stored_token = match file_profile.credential_store.as_deref() {
121            Some("keyring") if env_token.is_none() => crate::credentials::load_optional(&profile)?,
122            Some("file") | None => normalize_value(file_profile.token.clone()),
123            Some(other) => {
124                return Err(ApiError::InvalidInput(format!(
125                    "unsupported credential_store `{other}` for profile `{profile}`"
126                )));
127            }
128        };
129        let credential_store = if env_token.is_some() {
130            "environment"
131        } else if file_profile.credential_store.as_deref() == Some("keyring") {
132            "os-keychain"
133        } else if stored_token.is_some() {
134            if file_profile.credential_store.as_deref() == Some("file") {
135                "config-file"
136            } else {
137                "legacy-config"
138            }
139        } else {
140            "none"
141        }
142        .to_string();
143        let token = env_token.or(stored_token).ok_or_else(|| {
144            ApiError::InvalidInput(
145                "No API token configured. Set JIRA_TOKEN or run `jira auth login`.".into(),
146            )
147        })?;
148
149        // A blank value is absent, the same as for host, email and token: only a
150        // value someone actually wrote is worth rejecting.
151        let auth_type = match env_var("JIRA_AUTH_TYPE")
152            .or_else(|| normalize_value(file_profile.auth_type.clone()))
153        {
154            Some(v) => parse_auth_type(&v)?,
155            None => AuthType::default(),
156        };
157
158        let api_version = match env_var("JIRA_API_VERSION") {
159            Some(v) => parse_api_version(&v)?,
160            None => match file_profile.api_version {
161                Some(v) => validate_api_version(v)?,
162                None => 3,
163            },
164        };
165
166        // Email is required for Basic auth; PAT auth uses a token only.
167        let email = normalize_value(email_arg)
168            .or_else(|| env_var("JIRA_EMAIL"))
169            .or_else(|| normalize_value(file_profile.email));
170
171        let email = match auth_type {
172            AuthType::Basic => email.ok_or_else(|| {
173                ApiError::InvalidInput(
174                    "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
175                )
176            })?,
177            AuthType::Pat => email.unwrap_or_default(),
178        };
179
180        let read_only = match env_var("JIRA_READ_ONLY") {
181            Some(v) => parse_read_only(&v)?,
182            None => file_profile.read_only.unwrap_or(false),
183        };
184
185        let cloud_id = env_var("JIRA_CLOUD_ID").or(file_profile.cloud_id);
186        let token_kind = env_var("JIRA_TOKEN_KIND")
187            .or(file_profile.token_kind)
188            .unwrap_or_else(|| "classic".into());
189        if !matches!(token_kind.as_str(), "classic" | "scoped") {
190            return Err(ApiError::InvalidInput(format!(
191                "unsupported token_kind `{token_kind}`; expected classic or scoped"
192            )));
193        }
194        if token_kind == "scoped" && cloud_id.is_none() {
195            return Err(ApiError::InvalidInput(
196                "scoped Cloud token requires cloud_id; run `jira auth login` again".into(),
197            ));
198        }
199        let expires_at = file_profile.expires_at;
200        if let Some(value) = expires_at.as_deref() {
201            chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
202                ApiError::InvalidInput(format!("invalid expires_at `{value}`; expected YYYY-MM-DD"))
203            })?;
204        }
205
206        Ok(Self {
207            profile,
208            host,
209            email,
210            token,
211            auth_type,
212            api_version,
213            read_only,
214            credential_store,
215            cloud_id,
216            token_kind,
217            expires_at,
218        })
219    }
220}
221
222/// Render the set of selectable profile names. An empty set is named
223/// explicitly, so a config with no named profiles never produces a message
224/// ending in a bare `Available:` that reads as a truncated list.
225fn format_available(names: &[&str]) -> String {
226    if names.is_empty() {
227        "none defined".to_string()
228    } else {
229        names.join(", ")
230    }
231}
232
233pub fn config_path() -> PathBuf {
234    config_dir()
235        .unwrap_or_else(|| PathBuf::from(".config"))
236        .join("jira")
237        .join("config.toml")
238}
239
240pub fn schema_config_path() -> String {
241    config_path().display().to_string()
242}
243
244pub fn schema_config_path_description() -> &'static str {
245    #[cfg(target_os = "windows")]
246    {
247        "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
248    }
249
250    #[cfg(not(target_os = "windows"))]
251    {
252        "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
253    }
254}
255
256pub fn recommended_permissions(path: &std::path::Path) -> String {
257    #[cfg(target_os = "windows")]
258    {
259        format!(
260            "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
261            path.display()
262        )
263    }
264
265    #[cfg(not(target_os = "windows"))]
266    {
267        format!("chmod 600 {}", path.display())
268    }
269}
270
271pub fn schema_recommended_permissions_example() -> &'static str {
272    #[cfg(target_os = "windows")]
273    {
274        "Keep the file in your per-user %APPDATA% directory and out of shared folders."
275    }
276
277    #[cfg(not(target_os = "windows"))]
278    {
279        "chmod 600 /path/to/config.toml"
280    }
281}
282
283/// The `dcPatInstructions` value `init --json` prints when no host is known.
284///
285/// Rendered by the same function the command uses, so the schema example cannot
286/// drift from the URL a Data Center user is actually handed.
287pub fn schema_dc_pat_url_example() -> String {
288    dc_pat_url(None)
289}
290
291fn config_dir() -> Option<PathBuf> {
292    #[cfg(target_os = "windows")]
293    {
294        dirs::config_dir()
295    }
296
297    #[cfg(not(target_os = "windows"))]
298    {
299        std::env::var_os("XDG_CONFIG_HOME")
300            .filter(|value| !value.is_empty())
301            .map(PathBuf::from)
302            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
303    }
304}
305
306fn load_file_profile(profile: Option<&str>) -> Result<(String, ProfileConfig), ApiError> {
307    let path = config_path();
308    let content = match std::fs::read_to_string(&path) {
309        Ok(c) => c,
310        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
311            return Ok((
312                normalize_str(profile)
313                    .map(str::to_owned)
314                    .or_else(|| env_var("JIRA_PROFILE"))
315                    .unwrap_or_else(|| "default".into()),
316                ProfileConfig::default(),
317            ));
318        }
319        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
320    };
321
322    let raw: RawConfig = toml::from_str(&content)
323        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
324
325    let profile_name = normalize_str(profile)
326        .map(str::to_owned)
327        .or_else(|| env_var("JIRA_PROFILE"))
328        .or_else(|| raw.active_profile.clone());
329
330    match profile_name {
331        Some(name) if name == "default" => Ok((name, raw.default_profile())),
332        Some(name) => {
333            // BTreeMap gives sorted, deterministic output in error messages
334            let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
335            raw.profiles
336                .get(&name)
337                .cloned()
338                .map(|value| (name.clone(), value))
339                .ok_or_else(|| {
340                    ApiError::NotFound(format!(
341                        "profile '{name}' in config. Available: {}",
342                        format_available(&available)
343                    ))
344                })
345        }
346        None => Ok(("default".into(), raw.default_profile())),
347    }
348}
349
350/// Print the config file path and current resolved values (masking the token).
351pub fn show(
352    out: &OutputConfig,
353    host_arg: Option<String>,
354    email_arg: Option<String>,
355    profile_arg: Option<String>,
356) -> Result<(), ApiError> {
357    let path = config_path();
358    let cfg = Config::load(host_arg, email_arg, profile_arg)?;
359    let masked = mask_token(&cfg.token);
360
361    if out.json {
362        out.print_data(
363            &serde_json::to_string_pretty(&serde_json::json!({
364                "configPath": path,
365                "host": cfg.host,
366                "email": cfg.email,
367                "tokenMasked": masked,
368                "profile": cfg.profile,
369                "credentialStore": cfg.credential_store,
370                "tokenKind": cfg.token_kind,
371                "cloudId": cfg.cloud_id,
372                "expiresAt": cfg.expires_at,
373                "expirationStatus": expiration_status(cfg.expires_at.as_deref()),
374            }))
375            .expect("failed to serialize JSON"),
376        );
377    } else {
378        out.print_message(&format!("Config file: {}", path.display()));
379        out.print_data(&format!(
380            "profile: {}\nhost:  {}\nemail: {}\ntoken: {masked}\ncredential store: {}\ntoken kind: {}{}",
381            cfg.profile,
382            cfg.host,
383            cfg.email,
384            cfg.credential_store,
385            cfg.token_kind,
386            cfg.expires_at
387                .as_deref()
388                .map(|date| format!("\nexpires: {date}"))
389                .unwrap_or_default()
390        ));
391    }
392    Ok(())
393}
394
395pub async fn auth_status(
396    out: &OutputConfig,
397    host_arg: Option<String>,
398    email_arg: Option<String>,
399    profile_arg: Option<String>,
400    offline: bool,
401) -> Result<(), ApiError> {
402    let cfg = Config::load(host_arg, email_arg, profile_arg)?;
403    if offline {
404        out.print_result(
405            &serde_json::json!({
406                "profile": cfg.profile,
407                "status": "configured",
408                "verified": false,
409                "credentialStore": cfg.credential_store,
410                "tokenKind": cfg.token_kind,
411                "cloudId": cfg.cloud_id,
412                "expiresAt": cfg.expires_at,
413                "expirationStatus": expiration_status(cfg.expires_at.as_deref()),
414            }),
415            &format!(
416                "{} Profile '{}' is configured ({}, {}; network not checked)",
417                sym_ok(),
418                cfg.profile,
419                cfg.credential_store,
420                cfg.token_kind
421            ),
422        );
423        return Ok(());
424    }
425    let client = crate::api::client::JiraClient::new_with_cloud(
426        &cfg.host,
427        &cfg.email,
428        &cfg.token,
429        cfg.auth_type.clone(),
430        cfg.api_version,
431        cfg.cloud_id.as_deref(),
432        &cfg.token_kind,
433    )?;
434    let myself = client.get_myself().await?;
435    out.print_result(
436        &serde_json::json!({
437            "profile": cfg.profile,
438            "status": "ok",
439            "verified": true,
440            "identity": myself.display_name,
441            "credentialStore": cfg.credential_store,
442            "tokenKind": cfg.token_kind,
443            "cloudId": cfg.cloud_id,
444            "expiresAt": cfg.expires_at,
445            "expirationStatus": expiration_status(cfg.expires_at.as_deref()),
446        }),
447        &format!(
448            "{} Authenticated as {} ({}, {}; token {})",
449            sym_ok(),
450            myself.display_name,
451            cfg.credential_store,
452            cfg.token_kind,
453            expiration_status(cfg.expires_at.as_deref())
454        ),
455    );
456    Ok(())
457}
458
459fn expiration_status(expires_at: Option<&str>) -> &'static str {
460    let Some(expires_at) = expires_at else {
461        return "unknown";
462    };
463    let Ok(date) = chrono::NaiveDate::parse_from_str(expires_at, "%Y-%m-%d") else {
464        return "invalid";
465    };
466    let days = date
467        .signed_duration_since(chrono::Utc::now().date_naive())
468        .num_days();
469    if days < 0 {
470        "expired"
471    } else if days <= 30 {
472        "expiring-soon"
473    } else {
474        "valid"
475    }
476}
477
478pub async fn migrate_credential(
479    out: &OutputConfig,
480    profile_arg: Option<String>,
481) -> Result<(), ApiError> {
482    let cfg = Config::load(None, None, profile_arg)?;
483    if cfg.credential_store != "legacy-config" && cfg.credential_store != "config-file" {
484        return Err(ApiError::InvalidInput(format!(
485            "profile `{}` does not contain an inline token to migrate",
486            cfg.profile
487        )));
488    }
489    crate::api::client::JiraClient::new_with_cloud(
490        &cfg.host,
491        &cfg.email,
492        &cfg.token,
493        cfg.auth_type.clone(),
494        cfg.api_version,
495        cfg.cloud_id.as_deref(),
496        &cfg.token_kind,
497    )?
498    .get_myself()
499    .await?;
500
501    crate::credentials::available()?;
502    let previous = crate::credentials::load_optional(&cfg.profile)?;
503    crate::credentials::store(&cfg.profile, &cfg.token)?;
504    if let Err(error) = rewrite_profile_credential(&cfg.profile, Some("keyring")) {
505        match previous {
506            Some(token) => {
507                let _ = crate::credentials::store(&cfg.profile, &token);
508            }
509            None => {
510                let _ = crate::credentials::delete(&cfg.profile);
511            }
512        }
513        return Err(error);
514    }
515    out.print_result(
516        &serde_json::json!({
517            "profile": cfg.profile,
518            "migrated": true,
519            "credentialStore": "os-keychain",
520        }),
521        &format!(
522            "{} Migrated profile `{}` to the operating-system keychain",
523            sym_ok(),
524            cfg.profile
525        ),
526    );
527    Ok(())
528}
529
530pub fn logout(out: &OutputConfig, profile_arg: Option<String>) -> Result<(), ApiError> {
531    let profile = requested_profile_name(profile_arg.as_deref());
532    let (_, stored) = load_file_profile(Some(&profile))?;
533    let removed = if stored.credential_store.as_deref() == Some("keyring") {
534        crate::credentials::delete(&profile)?
535    } else {
536        false
537    };
538    rewrite_profile_credential(&profile, None)?;
539    out.print_result(
540        &serde_json::json!({ "profile": profile, "loggedOut": true, "credentialRemoved": removed }),
541        &format!("{} Logged out profile `{profile}`", sym_ok()),
542    );
543    Ok(())
544}
545
546/// Interactively set up the config file, or print JSON instructions when `--json` is used.
547///
548/// In JSON mode the function prints a machine-readable instructions object and returns.
549/// In an interactive terminal it prompts for Jira type, host, credentials, and profile
550/// name, verifies the credentials against the API, then writes (or updates)
551/// `~/.config/jira/config.toml`.
552pub async fn init(
553    out: &OutputConfig,
554    host: Option<&str>,
555    profile: Option<&str>,
556) -> Result<(), ApiError> {
557    if out.json {
558        init_json(out, host);
559        return Ok(());
560    }
561
562    use std::io::IsTerminal;
563    if !std::io::stdin().is_terminal() {
564        return Err(ApiError::InvalidInput(
565            "interactive setup requires a terminal; run `jira init --json` for setup instructions, or configure JIRA_HOST, JIRA_EMAIL, and JIRA_TOKEN for automation"
566                .into(),
567        ));
568    }
569
570    init_interactive(host, profile)
571        .await
572        .map_err(|error| ApiError::Other(error.to_string()))
573}
574
575/// The example config `jira init --json` prints, and the same value `jira schema`
576/// shows as the shape of that field.
577///
578/// One source, because these were two hand-maintained copies and the schema's had
579/// already fallen behind: it showed neither `auth_type` nor `api_version`, so the
580/// Data Center profile a reader needs in order to use a PAT was invisible there.
581pub fn schema_example_config() -> serde_json::Value {
582    serde_json::json!({
583        "default": {
584            "host": "mycompany.atlassian.net",
585            "email": "me@example.com",
586            "credential_store": "keyring",
587            "cloud_id": "your-atlassian-cloud-id",
588            "token_kind": "scoped",
589            "expires_at": "2026-11-24",
590            "auth_type": "basic",
591            "api_version": 3,
592            "read_only": true,
593        },
594        "profiles": {
595            "work": {
596                "host": "work.atlassian.net",
597                "email": "me@work.com",
598                "credential_store": "keyring",
599                "cloud_id": "your-work-cloud-id",
600                "token_kind": "scoped",
601            },
602            "datacenter": {
603                "host": "jira.mycompany.com",
604                "credential_store": "keyring",
605                "expires_at": "2026-11-24",
606                "auth_type": "pat",
607                "api_version": 2,
608            }
609        }
610    })
611}
612
613fn init_json(out: &OutputConfig, host: Option<&str>) {
614    let path = config_path();
615    let path_resolution = schema_config_path_description();
616    let permission_advice = recommended_permissions(&path);
617    let example = schema_example_config();
618
619    const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
620    let pat_url = dc_pat_url(host);
621
622    out.print_data(
623        &serde_json::to_string_pretty(&serde_json::json!({
624            "configPath": path,
625            "pathResolution": path_resolution,
626            "configExists": path.exists(),
627            "tokenInstructions": CLOUD_TOKEN_URL,
628            "dcPatInstructions": pat_url,
629            "recommendedPermissions": permission_advice,
630            "example": example,
631        }))
632        .expect("failed to serialize JSON"),
633    );
634}
635
636async fn init_interactive(
637    prefill_host: Option<&str>,
638    requested_profile: Option<&str>,
639) -> Result<(), Box<dyn std::error::Error>> {
640    let sep = sym_dim("──────────────");
641    eprintln!("Jira CLI Setup");
642    eprintln!("{sep}");
643
644    let path = config_path();
645
646    // Decide what to do: first run, update an existing profile, or add a new one.
647    //
648    // `target_name` holds the profile name to write:
649    //   Some(name) - already known (first run → "default"; update → chosen name)
650    //   None       - "add new" path, ask for name after credentials
651    let (target_name, existing): (Option<String>, Option<ProfileConfig>) =
652        if let Some(name) = requested_profile {
653            let existing = if path.exists() {
654                let profiles = list_profile_names(&path)?;
655                profiles
656                    .iter()
657                    .any(|candidate| candidate == name)
658                    .then(|| read_raw_profile(&path, name))
659                    .transpose()?
660            } else {
661                None
662            };
663            (Some(name.to_owned()), existing)
664        } else if path.exists() {
665            let profiles = list_profile_names(&path)?;
666
667            // Show the config path and each profile with its host so the user knows
668            // what exists before deciding whether to update or add.
669            eprintln!();
670            eprintln!(
671                "  {} {}",
672                sym_dim("Config:"),
673                sym_dim(&path.display().to_string())
674            );
675            eprintln!();
676            eprintln!("  {}:", sym_dim("Profiles"));
677            for name in &profiles {
678                let host = read_raw_profile(&path, name)
679                    .ok()
680                    .and_then(|p| p.host)
681                    .unwrap_or_default();
682                eprintln!("    {} {}  {}", sym_dim("•"), name, sym_dim(&host));
683            }
684            eprintln!();
685
686            let action = prompt("Action", "[update/add]", Some("update"))?;
687            eprintln!();
688
689            if !action.trim().eq_ignore_ascii_case("add") {
690                let default = profiles.first().map(String::as_str).unwrap_or("default");
691                let raw = if profiles.len() > 1 {
692                    prompt("Profile", "", Some(default))?
693                } else {
694                    default.to_owned()
695                };
696                let name = if raw.trim().is_empty() {
697                    default.to_owned()
698                } else {
699                    raw.trim().to_owned()
700                };
701                let cfg = read_raw_profile(&path, &name)?;
702                if profiles.len() > 1 {
703                    eprintln!();
704                }
705                (Some(name), Some(cfg))
706            } else {
707                (None, None)
708            }
709        } else {
710            // First run: silently use "default", no need to ask.
711            eprintln!();
712            (Some("default".to_owned()), None)
713        };
714
715    // Instance type - derive from existing config, or ask.
716    let is_cloud = if let Some(ref p) = existing {
717        p.auth_type.as_deref() != Some("pat")
718    } else {
719        let t = prompt("Type", sym_dim("[cloud/dc]").as_str(), Some("cloud"))?;
720        eprintln!();
721        !t.trim().eq_ignore_ascii_case("dc")
722    };
723
724    // Host
725    let host = if is_cloud {
726        let default_sub = existing
727            .as_ref()
728            .and_then(|p| p.host.clone())
729            .as_deref()
730            .or(prefill_host)
731            .map(|h| h.trim_end_matches(".atlassian.net").to_owned());
732        let raw = prompt_required("Subdomain", "", default_sub.as_deref())?;
733        let sub = raw.trim().trim_end_matches(".atlassian.net");
734        format!("{sub}.atlassian.net")
735    } else {
736        let default = existing
737            .as_ref()
738            .and_then(|p| p.host.clone())
739            .or_else(|| prefill_host.map(str::to_owned));
740        prompt_required("Host", "", default.as_deref())?
741    };
742
743    let prior_token = match (
744        existing
745            .as_ref()
746            .and_then(|profile| profile.credential_store.as_deref()),
747        target_name.as_deref(),
748    ) {
749        (Some("keyring"), Some(name)) => crate::credentials::load_optional(name)?,
750        _ => existing
751            .as_ref()
752            .and_then(|profile| profile.token.clone())
753            .filter(|token| !token.trim().is_empty()),
754    };
755
756    // Credentials
757    let (email, token, auth_type, api_version, cloud_id, token_kind, expires_at): (
758        Option<String>,
759        String,
760        &str,
761        u8,
762        Option<String>,
763        String,
764        Option<String>,
765    ) = if is_cloud {
766        const CLOUD_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
767        let default_email = existing.as_ref().and_then(|p| p.email.clone());
768        let email = prompt_required("Email", "", default_email.as_deref())?;
769        let default_kind = existing
770            .as_ref()
771            .and_then(|profile| profile.token_kind.as_deref())
772            .unwrap_or("scoped");
773        let requested_kind = prompt("Token type", "[scoped/classic]", Some(default_kind))?;
774        let token_kind = if requested_kind.eq_ignore_ascii_case("classic") {
775            "classic".to_owned()
776        } else {
777            "scoped".to_owned()
778        };
779        let cloud_id = if token_kind == "scoped" {
780            eprint!("  Discovering Cloud ID...");
781            std::io::stderr().flush().ok();
782            let id = discover_cloud_id(&host).await?;
783            eprintln!(" {}", sym_ok());
784            Some(id)
785        } else {
786            None
787        };
788        if prior_token.is_none()
789            && prompt_bool("Open Atlassian's token page now?", true)?
790            && let Err(error) = open::that(CLOUD_URL)
791        {
792            eprintln!("  {} Could not open browser: {error}", sym_fail());
793        }
794        eprintln!("  {}", sym_dim(&format!("→ {CLOUD_URL}")));
795        if token_kind == "scoped" {
796            eprintln!(
797                "  {}",
798                sym_dim("Choose Jira scopes and the least privilege needed for this profile.")
799            );
800        }
801        let token_hint = if prior_token.is_some() {
802            "(Enter to keep)"
803        } else {
804            ""
805        };
806        let raw = prompt_secret("Token", token_hint)?;
807        let kept_existing = raw.trim().is_empty();
808        let token = if kept_existing {
809            prior_token
810                .clone()
811                .ok_or("No existing token. Please enter a token.")?
812        } else {
813            raw
814        };
815        let expires_at = if kept_existing {
816            existing
817                .as_ref()
818                .and_then(|profile| profile.expires_at.clone())
819        } else {
820            Some(prompt_expiration_date(90)?)
821        };
822        (
823            Some(email),
824            token,
825            "basic",
826            3,
827            cloud_id,
828            token_kind,
829            expires_at,
830        )
831    } else {
832        let pat_url = dc_pat_url(Some(&host));
833        let (token, expires_at) = if let Some(existing_token) = prior_token.clone() {
834            print_dc_pat_link(&pat_url);
835            let raw = prompt_secret("Personal access token", "(Enter to keep)")?;
836            if raw.trim().is_empty() {
837                (
838                    existing_token,
839                    existing
840                        .as_ref()
841                        .and_then(|profile| profile.expires_at.clone()),
842                )
843            } else {
844                (raw, Some(prompt_expiration_date(90)?))
845            }
846        } else if prompt_bool("Create a dedicated PAT automatically?", true)? {
847            let method = prompt("Bootstrap with", "[password/pat]", Some("password"))?;
848            let use_pat = method.eq_ignore_ascii_case("pat");
849            let username = if use_pat {
850                None
851            } else {
852                Some(prompt_required("Bootstrap username", "", None)?)
853            };
854            let secret = prompt_secret(
855                if use_pat {
856                    "Existing personal access token"
857                } else {
858                    "Bootstrap password"
859                },
860                "used once and never saved",
861            )?;
862            let expiration_days = prompt_expiration_days(90)?;
863            eprint!("  Creating personal access token...");
864            std::io::stderr().flush().ok();
865            match create_data_center_pat(
866                &host,
867                username.as_deref(),
868                &secret,
869                target_name.as_deref().unwrap_or("jira-cli"),
870                expiration_days,
871            )
872            .await
873            {
874                Ok(token) => {
875                    eprintln!(" {}", sym_ok());
876                    (token, Some(expiration_date(expiration_days)))
877                }
878                Err(error) => {
879                    eprintln!(" {} {error}", sym_fail());
880                    eprintln!("  Falling back to browser-assisted PAT creation.");
881                    let _ = open::that(&pat_url);
882                    print_dc_pat_link(&pat_url);
883                    (
884                        prompt_secret("Personal access token", "")?,
885                        Some(prompt_expiration_date(90)?),
886                    )
887                }
888            }
889        } else {
890            let _ = open::that(&pat_url);
891            print_dc_pat_link(&pat_url);
892            (
893                prompt_secret("Personal access token", "")?,
894                Some(prompt_expiration_date(90)?),
895            )
896        };
897        let default_ver = existing
898            .as_ref()
899            .and_then(|p| p.api_version.map(|v| v.to_string()))
900            .unwrap_or_else(|| "2".to_owned());
901        let ver_str = prompt("API version", "", Some(&default_ver))?;
902        let api_version: u8 = ver_str.trim().parse().unwrap_or(2);
903        (
904            None,
905            token,
906            "pat",
907            api_version,
908            None,
909            "classic".to_owned(),
910            expires_at,
911        )
912    };
913
914    let default_read_only = existing
915        .as_ref()
916        .and_then(|profile| profile.read_only)
917        .unwrap_or(false);
918    let read_only = prompt_bool("Read-only mode?", default_read_only)?;
919
920    // Verify credentials against the API before writing anything.
921    use std::io::Write;
922    eprintln!();
923    eprint!("  Verifying credentials...");
924    std::io::stderr().flush().ok();
925
926    let auth_type_enum = if auth_type == "pat" {
927        AuthType::Pat
928    } else {
929        AuthType::Basic
930    };
931
932    let verified = match crate::api::client::JiraClient::new_with_cloud(
933        &host,
934        email.as_deref().unwrap_or(""),
935        &token,
936        auth_type_enum,
937        api_version,
938        cloud_id.as_deref(),
939        &token_kind,
940    ) {
941        Err(e) => {
942            eprintln!(" {} {e}", sym_fail());
943            return Err(e.into());
944        }
945        Ok(client) => match client.get_myself().await {
946            Ok(myself) => {
947                eprintln!(" {} Authenticated as {}", sym_ok(), myself.display_name);
948                true
949            }
950            Err(e) => {
951                eprintln!(" {} {e}", sym_fail());
952                eprintln!();
953                let save = prompt("Save config anyway?", sym_dim("[y/N]").as_str(), Some("n"))?;
954                save.trim().eq_ignore_ascii_case("y")
955            }
956        },
957    };
958
959    if !verified {
960        eprintln!();
961        eprintln!("{sep}");
962        return Ok(());
963    }
964
965    // Profile name - ask only when adding a new named profile.
966    let profile_name = match target_name {
967        Some(name) => name,
968        None => {
969            eprintln!();
970            let raw = prompt_required("Profile name", "", Some("default"))?;
971            if raw.trim().is_empty() {
972                "default".to_owned()
973            } else {
974                raw.trim().to_owned()
975            }
976        }
977    };
978
979    let file_storage = choose_credential_storage()?;
980    let previous_keyring = if file_storage {
981        None
982    } else {
983        crate::credentials::load_optional(&profile_name)?
984    };
985    if !file_storage {
986        crate::credentials::store(&profile_name, &token)?;
987    }
988
989    // Write config only after the credential is durable. Roll back a keychain
990    // change if the atomic config replacement fails.
991    let write_result = write_profile_to_config(
992        &path,
993        &profile_name,
994        ProfileWrite {
995            host: &host,
996            email: email.as_deref(),
997            token: &token,
998            credential_store: if file_storage { "file" } else { "keyring" },
999            cloud_id: cloud_id.as_deref(),
1000            token_kind: &token_kind,
1001            expires_at: expires_at.as_deref(),
1002            auth_type,
1003            api_version,
1004            read_only,
1005        },
1006    );
1007    if let Err(error) = write_result {
1008        if !file_storage {
1009            match previous_keyring {
1010                Some(previous) => {
1011                    let _ = crate::credentials::store(&profile_name, &previous);
1012                }
1013                None => {
1014                    let _ = crate::credentials::delete(&profile_name);
1015                }
1016            }
1017        }
1018        return Err(error);
1019    }
1020    if file_storage {
1021        let _ = crate::credentials::delete(&profile_name);
1022    }
1023
1024    #[cfg(unix)]
1025    {
1026        use std::os::unix::fs::PermissionsExt;
1027        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
1028    }
1029
1030    eprintln!();
1031    eprintln!("  {} Config written to {}", sym_ok(), path.display());
1032    eprintln!(
1033        "  {}",
1034        sym_dim(if file_storage {
1035            "Credential storage: protected config file; treat it as a secret"
1036        } else {
1037            "Credential storage: operating-system keychain"
1038        })
1039    );
1040    eprintln!("{sep}");
1041    if profile_name == "default" {
1042        eprintln!("  Run: jira projects list");
1043    } else {
1044        eprintln!("  Run: jira --profile {profile_name} projects list");
1045    }
1046    eprintln!();
1047
1048    Ok(())
1049}
1050
1051/// List all profile names present in the config file (default first, then named profiles).
1052fn list_profile_names(path: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1053    let content = std::fs::read_to_string(path)?;
1054    let doc: toml::Value = toml::from_str(&content)?;
1055    let table = doc.as_table().ok_or("config is not a TOML table")?;
1056
1057    let mut names = Vec::new();
1058    if table.contains_key("default") {
1059        names.push("default".to_owned());
1060    }
1061    if let Some(profiles) = table.get("profiles").and_then(toml::Value::as_table) {
1062        for name in profiles.keys() {
1063            names.push(name.clone());
1064        }
1065    }
1066    Ok(names)
1067}
1068
1069/// Read a single profile's raw values from the config file for use as pre-fill defaults.
1070fn read_raw_profile(
1071    path: &std::path::Path,
1072    name: &str,
1073) -> Result<ProfileConfig, Box<dyn std::error::Error>> {
1074    let content = std::fs::read_to_string(path)?;
1075    let raw: RawConfig = toml::from_str(&content)?;
1076    if name == "default" {
1077        Ok(raw.default_profile())
1078    } else {
1079        Ok(raw.profiles.get(name).cloned().unwrap_or_default())
1080    }
1081}
1082
1083/// Print `? Label  hint [default]: ` and read a line from stdin.
1084///
1085/// `hint` is shown dimmed between the label and the default bracket; pass `""` to omit it.
1086/// Returns the default string when the user presses Enter without typing.
1087fn prompt(label: &str, hint: &str, default: Option<&str>) -> Result<String, std::io::Error> {
1088    use std::io::{self, Write};
1089    let hint_part = if hint.is_empty() {
1090        String::new()
1091    } else {
1092        format!("  {hint}")
1093    };
1094    let default_part = match default {
1095        Some(d) if !d.is_empty() => format!(" [{d}]"),
1096        _ => String::new(),
1097    };
1098    eprint!("{} {label}{hint_part}{default_part}: ", sym_q());
1099    io::stderr().flush()?;
1100    let mut buf = String::new();
1101    io::stdin().read_line(&mut buf)?;
1102    let trimmed = buf.trim().to_owned();
1103    if trimmed.is_empty() {
1104        Ok(default.unwrap_or("").to_owned())
1105    } else {
1106        Ok(trimmed)
1107    }
1108}
1109
1110/// Like `prompt` but re-prompts until the user provides a non-empty value.
1111fn prompt_required(
1112    label: &str,
1113    hint: &str,
1114    default: Option<&str>,
1115) -> Result<String, std::io::Error> {
1116    loop {
1117        let value = prompt(label, hint, default)?;
1118        if !value.trim().is_empty() {
1119            return Ok(value);
1120        }
1121        eprintln!("  {} {label} is required.", sym_fail());
1122    }
1123}
1124
1125/// Prompt for a credential without echoing it to the terminal.
1126fn prompt_secret(label: &str, hint: &str) -> Result<String, std::io::Error> {
1127    use std::io::{self, Write};
1128    let hint_part = if hint.is_empty() {
1129        String::new()
1130    } else {
1131        format!("  {hint}")
1132    };
1133    eprint!("{} {label}{hint_part}: ", sym_q());
1134    io::stderr().flush()?;
1135    rpassword::read_password().map(|value| value.trim().to_owned())
1136}
1137
1138fn prompt_bool(label: &str, default: bool) -> Result<bool, std::io::Error> {
1139    let default_value = if default { "y" } else { "n" };
1140    let value = prompt(label, "[y/n]", Some(default_value))?;
1141    Ok(match value.to_ascii_lowercase().as_str() {
1142        "y" | "yes" | "true" | "1" => true,
1143        "n" | "no" | "false" | "0" => false,
1144        _ => default,
1145    })
1146}
1147
1148fn prompt_expiration_days(default: u64) -> Result<u64, Box<dyn std::error::Error>> {
1149    loop {
1150        let value = prompt(
1151            "Token expiry in days",
1152            "[1-365]",
1153            Some(&default.to_string()),
1154        )?;
1155        match value.parse::<u64>() {
1156            Ok(days @ 1..=365) => return Ok(days),
1157            _ => eprintln!("  {} Expiry must be between 1 and 365 days.", sym_fail()),
1158        }
1159    }
1160}
1161
1162fn expiration_date(days: u64) -> String {
1163    (chrono::Utc::now() + chrono::Duration::days(days as i64))
1164        .date_naive()
1165        .to_string()
1166}
1167
1168fn prompt_expiration_date(default: u64) -> Result<String, Box<dyn std::error::Error>> {
1169    Ok(expiration_date(prompt_expiration_days(default)?))
1170}
1171
1172fn choose_credential_storage() -> Result<bool, Box<dyn std::error::Error>> {
1173    match crate::credentials::available() {
1174        Ok(()) => Ok(false),
1175        Err(error) => {
1176            eprintln!("  {} {error}", sym_fail());
1177            if prompt_bool("Use the protected config-file fallback instead?", false)? {
1178                Ok(true)
1179            } else {
1180                Err("credential storage cancelled; start an OS credential service or use JIRA_TOKEN for this session".into())
1181            }
1182        }
1183    }
1184}
1185
1186async fn discover_cloud_id(host: &str) -> Result<String, Box<dyn std::error::Error>> {
1187    #[derive(Deserialize)]
1188    #[serde(rename_all = "camelCase")]
1189    struct TenantInfo {
1190        cloud_id: String,
1191    }
1192
1193    let site = if host.starts_with("http://") || host.starts_with("https://") {
1194        host.trim_end_matches('/').to_owned()
1195    } else {
1196        format!("https://{}", host.trim_end_matches('/'))
1197    };
1198    let info = reqwest::Client::new()
1199        .get(format!("{site}/_edge/tenant_info"))
1200        .send()
1201        .await?
1202        .error_for_status()?
1203        .json::<TenantInfo>()
1204        .await?;
1205    if info.cloud_id.trim().is_empty() {
1206        return Err("Atlassian returned an empty Cloud ID".into());
1207    }
1208    Ok(info.cloud_id)
1209}
1210
1211async fn create_data_center_pat(
1212    host: &str,
1213    username: Option<&str>,
1214    bootstrap_secret: &str,
1215    profile_name: &str,
1216    expiration_days: u64,
1217) -> Result<String, Box<dyn std::error::Error>> {
1218    let site = if host.starts_with("http://") || host.starts_with("https://") {
1219        host.trim_end_matches('/').to_owned()
1220    } else {
1221        format!("https://{}", host.trim_end_matches('/'))
1222    };
1223    let request = reqwest::Client::new()
1224        .post(format!("{site}/rest/pat/latest/tokens"))
1225        .json(&serde_json::json!({
1226            "name": format!("jira-cli / {profile_name}"),
1227            "expirationDuration": expiration_days,
1228        }));
1229    let request = match username {
1230        Some(username) => request.basic_auth(username, Some(bootstrap_secret)),
1231        None => request.bearer_auth(bootstrap_secret),
1232    };
1233    let response = request.send().await?;
1234    let status = response.status();
1235    if !status.is_success() {
1236        return Err(format!("PAT creation failed with HTTP {status}").into());
1237    }
1238    let body: serde_json::Value = response.json().await?;
1239    ["rawToken", "token"]
1240        .into_iter()
1241        .find_map(|field| body.get(field).and_then(serde_json::Value::as_str))
1242        .filter(|token| !token.is_empty())
1243        .map(str::to_owned)
1244        .ok_or_else(|| "PAT creation response did not contain the one-time token".into())
1245}
1246
1247// ── Color / symbol helpers ──────────────────────────────────────────────────
1248
1249fn sym_q() -> String {
1250    if crate::output::use_color() {
1251        use owo_colors::OwoColorize;
1252        "?".green().bold().to_string()
1253    } else {
1254        "?".to_owned()
1255    }
1256}
1257
1258fn sym_ok() -> String {
1259    if crate::output::use_color() {
1260        use owo_colors::OwoColorize;
1261        "✔".green().to_string()
1262    } else {
1263        "✔".to_owned()
1264    }
1265}
1266
1267fn sym_fail() -> String {
1268    if crate::output::use_color() {
1269        use owo_colors::OwoColorize;
1270        "✖".red().to_string()
1271    } else {
1272        "✖".to_owned()
1273    }
1274}
1275
1276fn sym_dim(s: &str) -> String {
1277    if crate::output::use_color() {
1278        use owo_colors::OwoColorize;
1279        s.dimmed().to_string()
1280    } else {
1281        s.to_owned()
1282    }
1283}
1284
1285/// Write or update a single profile section in the config file.
1286///
1287/// If the file already exists its other sections are preserved; only the target
1288/// profile section is created or replaced. The parent directory is created if needed.
1289struct ProfileWrite<'a> {
1290    host: &'a str,
1291    email: Option<&'a str>,
1292    token: &'a str,
1293    credential_store: &'a str,
1294    cloud_id: Option<&'a str>,
1295    token_kind: &'a str,
1296    expires_at: Option<&'a str>,
1297    auth_type: &'a str,
1298    api_version: u8,
1299    read_only: bool,
1300}
1301
1302fn write_profile_to_config(
1303    path: &std::path::Path,
1304    profile_name: &str,
1305    profile: ProfileWrite<'_>,
1306) -> Result<(), Box<dyn std::error::Error>> {
1307    let existing = if path.exists() {
1308        std::fs::read_to_string(path)?
1309    } else {
1310        String::new()
1311    };
1312
1313    let mut doc: toml::Value = if existing.trim().is_empty() {
1314        toml::Value::Table(toml::map::Map::new())
1315    } else {
1316        toml::from_str(&existing)?
1317    };
1318
1319    let root = doc.as_table_mut().expect("config is a TOML table");
1320    root.insert(
1321        "active_profile".to_owned(),
1322        toml::Value::String(profile_name.to_owned()),
1323    );
1324
1325    let mut section = toml::map::Map::new();
1326    section.insert(
1327        "host".to_owned(),
1328        toml::Value::String(profile.host.to_owned()),
1329    );
1330    if let Some(e) = profile.email {
1331        section.insert("email".to_owned(), toml::Value::String(e.to_owned()));
1332    }
1333    section.insert(
1334        "credential_store".to_owned(),
1335        toml::Value::String(profile.credential_store.to_owned()),
1336    );
1337    if profile.credential_store == "file" {
1338        section.insert(
1339            "token".to_owned(),
1340            toml::Value::String(profile.token.to_owned()),
1341        );
1342    }
1343    if let Some(cloud_id) = profile.cloud_id {
1344        section.insert(
1345            "cloud_id".to_owned(),
1346            toml::Value::String(cloud_id.to_owned()),
1347        );
1348    }
1349    if profile.token_kind != "classic" {
1350        section.insert(
1351            "token_kind".to_owned(),
1352            toml::Value::String(profile.token_kind.to_owned()),
1353        );
1354    }
1355    if let Some(expires_at) = profile.expires_at {
1356        section.insert(
1357            "expires_at".to_owned(),
1358            toml::Value::String(expires_at.to_owned()),
1359        );
1360    }
1361    if profile.auth_type != "basic" {
1362        section.insert(
1363            "auth_type".to_owned(),
1364            toml::Value::String(profile.auth_type.to_owned()),
1365        );
1366        section.insert(
1367            "api_version".to_owned(),
1368            toml::Value::Integer(i64::from(profile.api_version)),
1369        );
1370    }
1371    if profile.read_only {
1372        section.insert("read_only".to_owned(), toml::Value::Boolean(true));
1373    }
1374
1375    if profile_name == "default" {
1376        root.insert("default".to_owned(), toml::Value::Table(section));
1377    } else {
1378        let profiles = root
1379            .entry("profiles")
1380            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
1381        // A hand-edited config can carry `profiles` as a string or a number.
1382        // Reporting that is the whole job here: panicking loses the reason, and
1383        // replacing the value would delete whatever the user meant by it.
1384        let profiles = profiles.as_table_mut().ok_or_else(|| {
1385            format!(
1386                "{} defines `profiles` as something other than a table, so the `{profile_name}` profile cannot be added to it",
1387                path.display()
1388            )
1389        })?;
1390        profiles.insert(profile_name.to_owned(), toml::Value::Table(section));
1391    }
1392
1393    if let Some(parent) = path.parent() {
1394        std::fs::create_dir_all(parent)?;
1395    }
1396    let body = toml::to_string_pretty(&doc)?;
1397    let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
1398    let mut temp = tempfile::Builder::new()
1399        .prefix(".config-")
1400        .suffix(".toml.tmp")
1401        .tempfile_in(parent)?;
1402    use std::io::Write;
1403    temp.write_all(body.as_bytes())?;
1404    temp.flush()?;
1405    #[cfg(unix)]
1406    {
1407        use std::os::unix::fs::PermissionsExt;
1408        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
1409    }
1410    temp.persist(path).map_err(|error| error.error)?;
1411
1412    Ok(())
1413}
1414
1415/// Remove a named profile from the config file.
1416///
1417/// The "default" profile is removed by deleting the `[default]` section. Named profiles
1418/// are removed from the `[profiles]` table. Prints a success or error message; does not
1419/// write to stdout so it is safe in JSON mode.
1420fn requested_profile_name(profile: Option<&str>) -> String {
1421    normalize_str(profile)
1422        .map(str::to_owned)
1423        .or_else(|| env_var("JIRA_PROFILE"))
1424        .unwrap_or_else(|| "default".into())
1425}
1426
1427fn profile_table_mut<'a>(
1428    root: &'a mut toml::Table,
1429    profile_name: &str,
1430) -> Result<&'a mut toml::Table, ApiError> {
1431    if profile_name == "default" {
1432        if root.contains_key("default") {
1433            return root
1434                .get_mut("default")
1435                .and_then(toml::Value::as_table_mut)
1436                .ok_or_else(|| ApiError::Other("`default` is not a TOML table".into()));
1437        }
1438        return Ok(root);
1439    }
1440    root.get_mut("profiles")
1441        .and_then(toml::Value::as_table_mut)
1442        .and_then(|profiles| profiles.get_mut(profile_name))
1443        .and_then(toml::Value::as_table_mut)
1444        .ok_or_else(|| ApiError::NotFound(format!("profile `{profile_name}` in config")))
1445}
1446
1447fn write_toml_atomically(path: &std::path::Path, doc: &toml::Value) -> Result<(), ApiError> {
1448    let body = toml::to_string_pretty(doc)
1449        .map_err(|error| ApiError::Other(format!("Failed to serialize config: {error}")))?;
1450    let parent = path
1451        .parent()
1452        .ok_or_else(|| ApiError::Other("config path has no parent".into()))?;
1453    std::fs::create_dir_all(parent)
1454        .map_err(|error| ApiError::Other(format!("Failed to create config directory: {error}")))?;
1455    let mut temp = tempfile::Builder::new()
1456        .prefix(".config-")
1457        .suffix(".toml.tmp")
1458        .tempfile_in(parent)
1459        .map_err(|error| ApiError::Other(format!("Failed to create temporary config: {error}")))?;
1460    use std::io::Write;
1461    temp.write_all(body.as_bytes())
1462        .and_then(|()| temp.flush())
1463        .map_err(|error| ApiError::Other(format!("Failed to write temporary config: {error}")))?;
1464    #[cfg(unix)]
1465    {
1466        use std::os::unix::fs::PermissionsExt;
1467        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))
1468            .map_err(|error| ApiError::Other(format!("Failed to protect config: {error}")))?;
1469    }
1470    temp.persist(path)
1471        .map_err(|error| ApiError::Other(format!("Failed to replace config: {}", error.error)))?;
1472    Ok(())
1473}
1474
1475fn rewrite_profile_credential(
1476    profile_name: &str,
1477    credential_store: Option<&str>,
1478) -> Result<(), ApiError> {
1479    let path = config_path();
1480    let content = std::fs::read_to_string(&path)
1481        .map_err(|error| ApiError::Other(format!("Failed to read config: {error}")))?;
1482    let mut doc: toml::Value = toml::from_str(&content)
1483        .map_err(|error| ApiError::Other(format!("Failed to parse config: {error}")))?;
1484    let root = doc
1485        .as_table_mut()
1486        .ok_or_else(|| ApiError::Other("config is not a TOML table".into()))?;
1487    let profile = profile_table_mut(root, profile_name)?;
1488    profile.remove("token");
1489    match credential_store {
1490        Some(store) => {
1491            profile.insert("credential_store".into(), toml::Value::String(store.into()));
1492        }
1493        None => {
1494            profile.remove("credential_store");
1495        }
1496    }
1497    write_toml_atomically(&path, &doc)
1498}
1499
1500pub fn remove_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
1501    let path = config_path();
1502
1503    if !path.exists() {
1504        return Err(ApiError::NotFound(format!(
1505            "config file at {}",
1506            path.display()
1507        )));
1508    }
1509
1510    let content = std::fs::read_to_string(&path)
1511        .map_err(|e| ApiError::Other(format!("Failed to read config: {e}")))?;
1512    let mut doc: toml::Value = toml::from_str(&content)
1513        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
1514    let root = doc
1515        .as_table_mut()
1516        .ok_or_else(|| ApiError::Other("config is not a TOML table".to_string()))?;
1517
1518    let removed = if profile_name == "default" {
1519        root.remove("default").is_some()
1520    } else {
1521        root.get_mut("profiles")
1522            .and_then(toml::Value::as_table_mut)
1523            .and_then(|t| t.remove(profile_name))
1524            .is_some()
1525    };
1526
1527    if !removed {
1528        return Err(ApiError::NotFound(format!(
1529            "profile '{profile_name}' in config. Available: {}",
1530            format_available(&removable_profiles(root))
1531        )));
1532    }
1533
1534    if root.get("active_profile").and_then(toml::Value::as_str) == Some(profile_name) {
1535        let next = removable_profiles(root).first().copied().map(str::to_owned);
1536        match next {
1537            Some(name) => {
1538                root.insert("active_profile".into(), toml::Value::String(name));
1539            }
1540            None => {
1541                root.remove("active_profile");
1542            }
1543        }
1544    }
1545
1546    write_toml_atomically(&path, &doc)?;
1547    let _ = crate::credentials::delete(profile_name);
1548
1549    out.print_result(
1550        &serde_json::json!({ "profile": profile_name, "removed": true }),
1551        &format!("{} Removed profile '{profile_name}'", sym_ok()),
1552    );
1553    Ok(())
1554}
1555
1556pub fn print_config_path(out: &OutputConfig) -> Result<(), ApiError> {
1557    let path = config_path();
1558    out.print_result(
1559        &serde_json::json!({ "configPath": path }),
1560        &path.display().to_string(),
1561    );
1562    Ok(())
1563}
1564
1565pub fn list_profiles(out: &OutputConfig) -> Result<(), ApiError> {
1566    let path = config_path();
1567    let content = std::fs::read_to_string(&path)
1568        .map_err(|error| ApiError::Other(format!("Failed to read config: {error}")))?;
1569    let raw: RawConfig = toml::from_str(&content)
1570        .map_err(|error| ApiError::Other(format!("Failed to parse config: {error}")))?;
1571    let active = raw.active_profile.as_deref().unwrap_or("default");
1572    let mut items = Vec::new();
1573    if raw.default.host.is_some() || raw.host.is_some() {
1574        let profile = raw.default_profile();
1575        items.push(serde_json::json!({
1576            "name": "default", "host": profile.host, "active": active == "default"
1577        }));
1578    }
1579    items.extend(raw.profiles.iter().map(|(name, profile)| {
1580        serde_json::json!({
1581            "name": name, "host": profile.host, "active": active == name
1582        })
1583    }));
1584    if out.json {
1585        out.print_data(
1586            &serde_json::to_string_pretty(
1587                &serde_json::json!({"items": items, "total": items.len()}),
1588            )
1589            .expect("failed to serialize profiles"),
1590        );
1591    } else if items.is_empty() {
1592        out.print_data("No profiles configured. Run `jira init`.");
1593    } else {
1594        for item in items {
1595            let marker = if item["active"].as_bool().unwrap_or(false) {
1596                "*"
1597            } else {
1598                " "
1599            };
1600            out.print_data(&format!(
1601                "{marker} {:<20} {}",
1602                item["name"].as_str().unwrap_or_default(),
1603                item["host"].as_str().unwrap_or("-")
1604            ));
1605        }
1606    }
1607    Ok(())
1608}
1609
1610pub fn use_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
1611    let path = config_path();
1612    let content = std::fs::read_to_string(&path)
1613        .map_err(|error| ApiError::Other(format!("Failed to read config: {error}")))?;
1614    let mut doc: toml::Value = toml::from_str(&content)
1615        .map_err(|error| ApiError::Other(format!("Failed to parse config: {error}")))?;
1616    let root = doc
1617        .as_table_mut()
1618        .ok_or_else(|| ApiError::Other("config is not a TOML table".into()))?;
1619    if !removable_profiles(root).contains(&profile_name) {
1620        return Err(ApiError::NotFound(format!(
1621            "profile '{profile_name}' in config"
1622        )));
1623    }
1624    root.insert(
1625        "active_profile".into(),
1626        toml::Value::String(profile_name.into()),
1627    );
1628    write_toml_atomically(&path, &doc)?;
1629    out.print_result(
1630        &serde_json::json!({"profile": profile_name, "active": true}),
1631        &format!("{} Active profile set to '{profile_name}'", sym_ok()),
1632    );
1633    Ok(())
1634}
1635
1636/// Names `config remove` accepts, in deterministic order: the `default`
1637/// section when present, then each `[profiles.*]` key.
1638fn removable_profiles(root: &toml::Table) -> Vec<&str> {
1639    let mut names: Vec<&str> = Vec::new();
1640    if root.contains_key("default") {
1641        names.push("default");
1642    }
1643    if let Some(profiles) = root.get("profiles").and_then(toml::Value::as_table) {
1644        names.extend(profiles.keys().map(String::as_str));
1645    }
1646    names
1647}
1648
1649// The selectedTab plugin key differs between Jira releases. The profile page is
1650// stable and always exposes Personal access tokens in the profile navigation.
1651const PAT_PATH: &str = "/secure/ViewProfile.jspa";
1652const PAT_NAVIGATION: &str = "Profile → Personal access tokens";
1653
1654/// Build the Personal Access Token creation URL for a Jira DC/Server instance.
1655///
1656/// When `host` is known the full URL is returned so the user can click it directly.
1657/// When unknown a placeholder template is returned.
1658fn dc_pat_url(host: Option<&str>) -> String {
1659    match host {
1660        Some(h) => {
1661            let base = if h.starts_with("http://") || h.starts_with("https://") {
1662                h.trim_end_matches('/').to_string()
1663            } else {
1664                format!("https://{}", h.trim_end_matches('/'))
1665            };
1666            format!("{base}{PAT_PATH}")
1667        }
1668        None => format!("http://<your-host>{PAT_PATH}"),
1669    }
1670}
1671
1672fn print_dc_pat_link(url: &str) {
1673    eprintln!("  {}", sym_dim(&format!("→ {url}")));
1674    eprintln!("  {}", sym_dim(PAT_NAVIGATION));
1675}
1676
1677/// Mask a token for display, showing only the last 4 characters.
1678///
1679/// Atlassian tokens begin with a predictable prefix, so showing the
1680/// start provides no meaningful identification - the end is more useful.
1681fn mask_token(token: &str) -> String {
1682    let n = token.chars().count();
1683    if n > 4 {
1684        let suffix: String = token.chars().skip(n - 4).collect();
1685        format!("***{suffix}")
1686    } else {
1687        "***".into()
1688    }
1689}
1690
1691fn env_var(name: &str) -> Option<String> {
1692    std::env::var(name)
1693        .ok()
1694        .and_then(|value| normalize_value(Some(value)))
1695}
1696
1697/// The values every boolean environment variable in this CLI reads as on and
1698/// off, matched case-insensitively.
1699///
1700/// Public because `jira schema` declares them: an agent should not have to guess
1701/// which spellings the safety switch accepts.
1702pub const TRUTHY: &[&str] = &["1", "true", "yes", "on"];
1703pub const FALSY: &[&str] = &["0", "false", "no", "off"];
1704
1705/// Whether a diagnostics-only toggle is switched on.
1706///
1707/// An unrecognised value means off here, because failing a command outright over
1708/// a typo in a debug switch costs more than the missed logging. Safety switches
1709/// use `parse_read_only` instead, which refuses.
1710pub fn is_truthy(value: &str) -> bool {
1711    TRUTHY.contains(&value.trim().to_ascii_lowercase().as_str())
1712}
1713
1714/// Parse `JIRA_READ_ONLY`, rejecting anything that is neither an on nor an off
1715/// value.
1716///
1717/// The guard is a safety control, so an unrecognised value must not resolve to
1718/// "off": `JIRA_READ_ONLY=enabled` would then read as protection while every
1719/// write went through. Refusing to start is the only answer that cannot be
1720/// mistaken for the setting having worked.
1721fn parse_read_only(value: &str) -> Result<bool, ApiError> {
1722    let v = value.to_ascii_lowercase();
1723    if TRUTHY.contains(&v.as_str()) {
1724        Ok(true)
1725    } else if FALSY.contains(&v.as_str()) {
1726        Ok(false)
1727    } else {
1728        Err(ApiError::InvalidInput(format!(
1729            "JIRA_READ_ONLY is set to '{value}', which is neither on ({}) nor off ({}). \
1730             Refusing to run rather than guess whether writes are meant to be blocked.",
1731            TRUTHY.join(", "),
1732            FALSY.join(", ")
1733        )))
1734    }
1735}
1736
1737/// Parse an `auth_type` from the environment or the config file.
1738///
1739/// A typo must not fall back to basic auth: on a Data Center instance that turns
1740/// "you misspelled pat" into an opaque 401 from Jira.
1741fn parse_auth_type(value: &str) -> Result<AuthType, ApiError> {
1742    if value.eq_ignore_ascii_case("basic") {
1743        Ok(AuthType::Basic)
1744    } else if value.eq_ignore_ascii_case("pat") {
1745        Ok(AuthType::Pat)
1746    } else {
1747        Err(ApiError::InvalidInput(format!(
1748            "auth_type '{value}' is not recognised. Use 'basic' (Jira Cloud) or \
1749             'pat' (Jira Data Center/Server)."
1750        )))
1751    }
1752}
1753
1754/// Jira REST API versions this CLI knows how to talk to.
1755const API_VERSIONS: &[u8] = &[2, 3];
1756
1757fn parse_api_version(value: &str) -> Result<u8, ApiError> {
1758    let parsed = value.parse::<u8>().map_err(|_| {
1759        ApiError::InvalidInput(format!(
1760            "api_version '{value}' is not a number. Use 3 (Jira Cloud) or 2 \
1761             (Jira Data Center/Server)."
1762        ))
1763    })?;
1764    validate_api_version(parsed)
1765}
1766
1767/// Reject a version the client has no URL scheme for, rather than building
1768/// requests against `/rest/api/<n>/` and reporting Jira's 404 as the problem.
1769fn validate_api_version(version: u8) -> Result<u8, ApiError> {
1770    if API_VERSIONS.contains(&version) {
1771        Ok(version)
1772    } else {
1773        Err(ApiError::InvalidInput(format!(
1774            "api_version {version} is not supported. Use 3 (Jira Cloud) or 2 \
1775             (Jira Data Center/Server)."
1776        )))
1777    }
1778}
1779
1780fn normalize_value(value: Option<String>) -> Option<String> {
1781    value.and_then(|value| {
1782        let trimmed = value.trim();
1783        if trimmed.is_empty() {
1784            None
1785        } else {
1786            Some(trimmed.to_string())
1787        }
1788    })
1789}
1790
1791fn normalize_str(value: Option<&str>) -> Option<&str> {
1792    value.and_then(|value| {
1793        let trimmed = value.trim();
1794        if trimmed.is_empty() {
1795            None
1796        } else {
1797            Some(trimmed)
1798        }
1799    })
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804    use super::*;
1805    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
1806    use tempfile::TempDir;
1807
1808    #[test]
1809    fn mask_token_long() {
1810        let masked = mask_token("ATATxxx1234abcd");
1811        assert!(masked.starts_with("***"));
1812        assert!(masked.ends_with("abcd"));
1813    }
1814
1815    #[test]
1816    fn read_only_accepts_its_documented_values_in_any_case() {
1817        for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "On"] {
1818            assert!(parse_read_only(on).unwrap(), "{on} should enable the guard");
1819        }
1820        for off in ["0", "false", "FALSE", "no", "No", "off", "OFF"] {
1821            assert!(
1822                !parse_read_only(off).unwrap(),
1823                "{off} should disable the guard"
1824            );
1825        }
1826    }
1827
1828    /// The dangerous direction: a value nobody recognises must not quietly mean
1829    /// "writes allowed", because the operator who set it believes the opposite.
1830    #[test]
1831    fn read_only_refuses_a_value_it_does_not_understand() {
1832        for bad in ["enabled", "ture", "2", "y", "readonly"] {
1833            let err = parse_read_only(bad).unwrap_err();
1834            let message = err.to_string();
1835            assert!(
1836                message.contains(bad),
1837                "the rejection must quote the offending value; got: {message}"
1838            );
1839            assert!(
1840                matches!(err, ApiError::InvalidInput(_)),
1841                "{bad} must be reported as bad input, not as a Jira failure"
1842            );
1843        }
1844    }
1845
1846    /// A diagnostics switch reads an unknown value as off, which is the opposite
1847    /// policy from the read-only guard above and deliberately so.
1848    #[test]
1849    fn is_truthy_accepts_any_case_and_treats_the_unknown_as_off() {
1850        for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "  on  "] {
1851            assert!(is_truthy(on), "{on} should read as on");
1852        }
1853        for off in ["0", "false", "no", "off", "enabled", "ture", ""] {
1854            assert!(!is_truthy(off), "{off} should read as off");
1855        }
1856    }
1857
1858    /// A blank `auth_type` is an unset one, not a typo to refuse. Otherwise a
1859    /// config file with an empty placeholder stops every command.
1860    #[test]
1861    fn load_blank_auth_type_in_the_config_file_is_treated_as_unset() {
1862        let _lock = ProcessEnvLock::acquire();
1863        let dir = TempDir::new().unwrap();
1864        write_config(
1865            dir.path(),
1866            "[default]\nhost = \"x.atlassian.net\"\nemail = \"me@example.com\"\n\
1867             token = \"t\"\nauth_type = \"  \"\n",
1868        )
1869        .unwrap();
1870        let _config_dir = set_config_dir_env(dir.path());
1871        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1872        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1873
1874        let config = Config::load(None, None, None).unwrap();
1875        assert_eq!(config.auth_type, AuthType::Basic);
1876    }
1877
1878    #[test]
1879    fn auth_type_refuses_a_typo_rather_than_falling_back_to_basic() {
1880        assert_eq!(parse_auth_type("pat").unwrap(), AuthType::Pat);
1881        assert_eq!(parse_auth_type("PAT").unwrap(), AuthType::Pat);
1882        assert_eq!(parse_auth_type("basic").unwrap(), AuthType::Basic);
1883
1884        let err = parse_auth_type("ptt").unwrap_err().to_string();
1885        assert!(err.contains("ptt"), "got: {err}");
1886        assert!(
1887            err.contains("pat"),
1888            "the message must name the real spelling"
1889        );
1890    }
1891
1892    #[test]
1893    fn api_version_refuses_anything_the_client_cannot_address() {
1894        assert_eq!(parse_api_version("2").unwrap(), 2);
1895        assert_eq!(parse_api_version("3").unwrap(), 3);
1896
1897        for bad in ["v3", "", "3.0", "latest"] {
1898            assert!(
1899                parse_api_version(bad).is_err(),
1900                "{bad} is not a version number"
1901            );
1902        }
1903        // Parses as a u8 and is still wrong: there is no /rest/api/7/.
1904        let err = parse_api_version("7").unwrap_err().to_string();
1905        assert!(err.contains('7'), "got: {err}");
1906    }
1907
1908    #[test]
1909    fn mask_token_short() {
1910        assert_eq!(mask_token("abc"), "***");
1911    }
1912
1913    #[test]
1914    fn mask_token_unicode_safe() {
1915        // Ensure char-based indexing doesn't panic on multi-byte chars
1916        let token = "token-日本語-end";
1917        let result = mask_token(token);
1918        assert!(result.starts_with("***"));
1919    }
1920
1921    #[test]
1922    #[cfg(not(target_os = "windows"))]
1923    fn config_path_prefers_xdg_config_home() {
1924        let _env = ProcessEnvLock::acquire().unwrap();
1925        let dir = TempDir::new().unwrap();
1926        let _config_dir = set_config_dir_env(dir.path());
1927
1928        assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
1929    }
1930
1931    #[test]
1932    fn load_ignores_blank_env_vars_and_falls_back_to_file() {
1933        let _env = ProcessEnvLock::acquire().unwrap();
1934        let dir = TempDir::new().unwrap();
1935        write_config(
1936            dir.path(),
1937            r#"
1938[default]
1939host = "work.atlassian.net"
1940email = "me@example.com"
1941token = "secret-token"
1942"#,
1943        )
1944        .unwrap();
1945
1946        let _config_dir = set_config_dir_env(dir.path());
1947        let _host = EnvVarGuard::set("JIRA_HOST", "   ");
1948        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1949        let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
1950        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1951
1952        let cfg = Config::load(None, None, None).unwrap();
1953        assert_eq!(cfg.host, "work.atlassian.net");
1954        assert_eq!(cfg.email, "me@example.com");
1955        assert_eq!(cfg.token, "secret-token");
1956    }
1957
1958    #[test]
1959    fn load_accepts_documented_default_section() {
1960        let _env = ProcessEnvLock::acquire().unwrap();
1961        let dir = TempDir::new().unwrap();
1962        write_config(
1963            dir.path(),
1964            r#"
1965[default]
1966host = "example.atlassian.net"
1967email = "me@example.com"
1968token = "secret-token"
1969"#,
1970        )
1971        .unwrap();
1972
1973        let _config_dir = set_config_dir_env(dir.path());
1974        let _host = EnvVarGuard::unset("JIRA_HOST");
1975        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1976        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1977        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1978
1979        let cfg = Config::load(None, None, None).unwrap();
1980        assert_eq!(cfg.host, "example.atlassian.net");
1981        assert_eq!(cfg.email, "me@example.com");
1982        assert_eq!(cfg.token, "secret-token");
1983    }
1984
1985    #[test]
1986    fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
1987        let _env = ProcessEnvLock::acquire().unwrap();
1988        let dir = TempDir::new().unwrap();
1989        let _config_dir = set_config_dir_env(dir.path());
1990        let _host = EnvVarGuard::set("JIRA_HOST", "");
1991        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1992        let _token = EnvVarGuard::set("JIRA_TOKEN", "");
1993        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1994
1995        let err = Config::load(None, None, None).unwrap_err();
1996        assert!(matches!(err, ApiError::InvalidInput(_)));
1997        assert!(err.to_string().contains("No Jira host configured"));
1998    }
1999
2000    #[test]
2001    fn permission_guidance_matches_platform() {
2002        let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
2003
2004        #[cfg(target_os = "windows")]
2005        assert!(guidance.contains("AppData"));
2006
2007        #[cfg(not(target_os = "windows"))]
2008        assert!(guidance.starts_with("chmod 600 "));
2009    }
2010
2011    // ── Priority: CLI > env > file ─────────────────────────────────────────────
2012
2013    #[test]
2014    fn load_env_host_overrides_file() {
2015        let _env = ProcessEnvLock::acquire().unwrap();
2016        let dir = TempDir::new().unwrap();
2017        write_config(
2018            dir.path(),
2019            r#"
2020[default]
2021host = "file.atlassian.net"
2022email = "me@example.com"
2023token = "tok"
2024"#,
2025        )
2026        .unwrap();
2027
2028        let _config_dir = set_config_dir_env(dir.path());
2029        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
2030        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2031        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2032        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2033
2034        let cfg = Config::load(None, None, None).unwrap();
2035        assert_eq!(cfg.host, "env.atlassian.net");
2036    }
2037
2038    #[test]
2039    fn load_cli_host_arg_overrides_env_and_file() {
2040        let _env = ProcessEnvLock::acquire().unwrap();
2041        let dir = TempDir::new().unwrap();
2042        write_config(
2043            dir.path(),
2044            r#"
2045[default]
2046host = "file.atlassian.net"
2047email = "me@example.com"
2048token = "tok"
2049"#,
2050        )
2051        .unwrap();
2052
2053        let _config_dir = set_config_dir_env(dir.path());
2054        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
2055        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2056        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2057        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2058
2059        let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
2060        assert_eq!(cfg.host, "cli.atlassian.net");
2061    }
2062
2063    // ── Error cases ────────────────────────────────────────────────────────────
2064
2065    #[test]
2066    fn load_missing_token_returns_error() {
2067        let _env = ProcessEnvLock::acquire().unwrap();
2068        let dir = TempDir::new().unwrap();
2069        let _config_dir = set_config_dir_env(dir.path());
2070        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
2071        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
2072        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2073        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2074
2075        let err = Config::load(None, None, None).unwrap_err();
2076        assert!(matches!(err, ApiError::InvalidInput(_)));
2077        assert!(err.to_string().contains("No API token"));
2078    }
2079
2080    #[test]
2081    fn load_missing_email_for_basic_auth_returns_error() {
2082        let _env = ProcessEnvLock::acquire().unwrap();
2083        let dir = TempDir::new().unwrap();
2084        let _config_dir = set_config_dir_env(dir.path());
2085        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
2086        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2087        let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
2088        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
2089        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2090
2091        let err = Config::load(None, None, None).unwrap_err();
2092        assert!(matches!(err, ApiError::InvalidInput(_)));
2093        assert!(err.to_string().contains("No email configured"));
2094    }
2095
2096    #[test]
2097    fn load_invalid_toml_returns_error() {
2098        let _env = ProcessEnvLock::acquire().unwrap();
2099        let dir = TempDir::new().unwrap();
2100        write_config(dir.path(), "host = [invalid toml").unwrap();
2101
2102        let _config_dir = set_config_dir_env(dir.path());
2103        let _host = EnvVarGuard::unset("JIRA_HOST");
2104        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2105        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2106
2107        let err = Config::load(None, None, None).unwrap_err();
2108        assert!(matches!(err, ApiError::Other(_)));
2109        assert!(err.to_string().contains("parse"));
2110    }
2111
2112    // ── Auth type ──────────────────────────────────────────────────────────────
2113
2114    #[test]
2115    fn load_pat_auth_does_not_require_email() {
2116        let _env = ProcessEnvLock::acquire().unwrap();
2117        let dir = TempDir::new().unwrap();
2118        write_config(
2119            dir.path(),
2120            r#"
2121[default]
2122host = "jira.corp.com"
2123token = "my-pat-token"
2124auth_type = "pat"
2125api_version = 2
2126"#,
2127        )
2128        .unwrap();
2129
2130        let _config_dir = set_config_dir_env(dir.path());
2131        let _host = EnvVarGuard::unset("JIRA_HOST");
2132        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2133        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2134        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
2135        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2136
2137        let cfg = Config::load(None, None, None).unwrap();
2138        assert_eq!(cfg.auth_type, AuthType::Pat);
2139        assert_eq!(cfg.api_version, 2);
2140        assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
2141    }
2142
2143    #[test]
2144    fn load_jira_auth_type_env_pat_overrides_basic() {
2145        let _env = ProcessEnvLock::acquire().unwrap();
2146        let dir = TempDir::new().unwrap();
2147        write_config(
2148            dir.path(),
2149            r#"
2150[default]
2151host = "jira.corp.com"
2152email = "me@example.com"
2153token = "tok"
2154auth_type = "basic"
2155"#,
2156        )
2157        .unwrap();
2158
2159        let _config_dir = set_config_dir_env(dir.path());
2160        let _host = EnvVarGuard::unset("JIRA_HOST");
2161        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2162        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2163        let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
2164        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2165
2166        let cfg = Config::load(None, None, None).unwrap();
2167        assert_eq!(cfg.auth_type, AuthType::Pat);
2168    }
2169
2170    #[test]
2171    fn load_jira_api_version_env_overrides_default() {
2172        let _env = ProcessEnvLock::acquire().unwrap();
2173        let dir = TempDir::new().unwrap();
2174        let _config_dir = set_config_dir_env(dir.path());
2175        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
2176        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
2177        let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
2178        let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
2179        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
2180        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2181
2182        let cfg = Config::load(None, None, None).unwrap();
2183        assert_eq!(cfg.api_version, 2);
2184    }
2185
2186    // ── Profile selection ──────────────────────────────────────────────────────
2187
2188    #[test]
2189    fn load_profile_arg_selects_named_section() {
2190        let _env = ProcessEnvLock::acquire().unwrap();
2191        let dir = TempDir::new().unwrap();
2192        write_config(
2193            dir.path(),
2194            r#"
2195[default]
2196host = "default.atlassian.net"
2197email = "default@example.com"
2198token = "default-tok"
2199
2200[profiles.work]
2201host = "work.atlassian.net"
2202email = "me@work.com"
2203token = "work-tok"
2204"#,
2205        )
2206        .unwrap();
2207
2208        let _config_dir = set_config_dir_env(dir.path());
2209        let _host = EnvVarGuard::unset("JIRA_HOST");
2210        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2211        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2212        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2213
2214        let cfg = Config::load(None, None, Some("work".into())).unwrap();
2215        assert_eq!(cfg.host, "work.atlassian.net");
2216        assert_eq!(cfg.email, "me@work.com");
2217        assert_eq!(cfg.token, "work-tok");
2218    }
2219
2220    #[test]
2221    fn load_jira_profile_env_selects_named_section() {
2222        let _env = ProcessEnvLock::acquire().unwrap();
2223        let dir = TempDir::new().unwrap();
2224        write_config(
2225            dir.path(),
2226            r#"
2227[default]
2228host = "default.atlassian.net"
2229email = "default@example.com"
2230token = "default-tok"
2231
2232[profiles.staging]
2233host = "staging.atlassian.net"
2234email = "me@staging.com"
2235token = "staging-tok"
2236"#,
2237        )
2238        .unwrap();
2239
2240        let _config_dir = set_config_dir_env(dir.path());
2241        let _host = EnvVarGuard::unset("JIRA_HOST");
2242        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2243        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2244        let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
2245
2246        let cfg = Config::load(None, None, None).unwrap();
2247        assert_eq!(cfg.host, "staging.atlassian.net");
2248    }
2249
2250    #[test]
2251    fn load_uses_active_profile_when_no_override_is_set() {
2252        let _env = ProcessEnvLock::acquire().unwrap();
2253        let dir = TempDir::new().unwrap();
2254        write_config(
2255            dir.path(),
2256            r#"
2257active_profile = "work"
2258
2259[default]
2260host = "default.atlassian.net"
2261email = "default@example.com"
2262token = "default-tok"
2263
2264[profiles.work]
2265host = "work.atlassian.net"
2266email = "me@work.com"
2267token = "work-tok"
2268"#,
2269        )
2270        .unwrap();
2271
2272        let _config_dir = set_config_dir_env(dir.path());
2273        let _host = EnvVarGuard::unset("JIRA_HOST");
2274        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2275        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2276        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2277
2278        let cfg = Config::load(None, None, None).unwrap();
2279        assert_eq!(cfg.profile, "work");
2280        assert_eq!(cfg.host, "work.atlassian.net");
2281    }
2282
2283    #[test]
2284    fn load_unknown_profile_returns_descriptive_error() {
2285        let _env = ProcessEnvLock::acquire().unwrap();
2286        let dir = TempDir::new().unwrap();
2287        write_config(
2288            dir.path(),
2289            r#"
2290[profiles.alpha]
2291host = "alpha.atlassian.net"
2292email = "me@alpha.com"
2293token = "alpha-tok"
2294"#,
2295        )
2296        .unwrap();
2297
2298        let _config_dir = set_config_dir_env(dir.path());
2299        let _host = EnvVarGuard::unset("JIRA_HOST");
2300        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2301        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2302
2303        let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
2304        assert!(
2305            matches!(err, ApiError::NotFound(_)),
2306            "selecting a profile that is not in the config is a not_found condition, \
2307             not an unexpected error: {err:?}"
2308        );
2309        let msg = err.to_string();
2310        assert!(
2311            msg.contains("nonexistent"),
2312            "error should name the bad profile"
2313        );
2314        assert!(
2315            msg.contains("alpha"),
2316            "error should list available profiles"
2317        );
2318    }
2319
2320    // ── config::show ───────────────────────────────────────────────────────────
2321
2322    #[test]
2323    fn show_json_output_includes_host_and_masked_token() {
2324        let _env = ProcessEnvLock::acquire().unwrap();
2325        let dir = TempDir::new().unwrap();
2326        write_config(
2327            dir.path(),
2328            r#"
2329[default]
2330host = "show-test.atlassian.net"
2331email = "me@example.com"
2332token = "supersecrettoken"
2333"#,
2334        )
2335        .unwrap();
2336
2337        let _config_dir = set_config_dir_env(dir.path());
2338        let _host = EnvVarGuard::unset("JIRA_HOST");
2339        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2340        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2341        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2342
2343        let out = crate::output::OutputConfig::new(true, false, true);
2344        // Must not error and must produce no error output
2345        show(&out, None, None, None).unwrap();
2346    }
2347
2348    #[test]
2349    fn show_text_output_renders_without_error() {
2350        let _env = ProcessEnvLock::acquire().unwrap();
2351        let dir = TempDir::new().unwrap();
2352        write_config(
2353            dir.path(),
2354            r#"
2355[default]
2356host = "show-test.atlassian.net"
2357email = "me@example.com"
2358token = "supersecrettoken"
2359"#,
2360        )
2361        .unwrap();
2362
2363        let _config_dir = set_config_dir_env(dir.path());
2364        let _host = EnvVarGuard::unset("JIRA_HOST");
2365        let _email = EnvVarGuard::unset("JIRA_EMAIL");
2366        let _token = EnvVarGuard::unset("JIRA_TOKEN");
2367        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2368
2369        let out = crate::output::OutputConfig::new(false, false, true);
2370        show(&out, None, None, None).unwrap();
2371    }
2372
2373    // ── config::init ───────────────────────────────────────────────────────────
2374
2375    #[tokio::test]
2376    async fn init_json_output_includes_example_and_paths() {
2377        let out = crate::output::OutputConfig::new(true, false, true);
2378        // No env or config needed - init() never loads credentials in JSON mode
2379        init(&out, Some("jira.corp.com"), None).await.unwrap();
2380    }
2381
2382    // The text path of init() requires an interactive TTY; in test context stdin is
2383    // not a TTY, so it returns an actionable input error without hanging.
2384    #[tokio::test]
2385    async fn init_non_interactive_returns_actionable_error() {
2386        let out = crate::output::OutputConfig {
2387            json: false,
2388            quiet: false,
2389        };
2390        // stdin is not a TTY in tests - must return immediately, not hang
2391        let error = init(&out, None, None).await.unwrap_err();
2392        assert!(matches!(error, ApiError::InvalidInput(_)));
2393        assert!(error.to_string().contains("JIRA_TOKEN"));
2394    }
2395
2396    #[test]
2397    fn write_profile_to_config_creates_default_profile() {
2398        let dir = TempDir::new().unwrap();
2399        let path = dir.path().join("jira").join("config.toml");
2400
2401        write_profile_to_config(
2402            &path,
2403            "default",
2404            ProfileWrite {
2405                host: "acme.atlassian.net",
2406                email: Some("me@acme.com"),
2407                token: "secret",
2408                credential_store: "file",
2409                cloud_id: None,
2410                token_kind: "classic",
2411                expires_at: None,
2412                auth_type: "basic",
2413                api_version: 3,
2414                read_only: false,
2415            },
2416        )
2417        .unwrap();
2418
2419        let content = std::fs::read_to_string(&path).unwrap();
2420        assert!(content.contains("acme.atlassian.net"));
2421        assert!(content.contains("me@acme.com"));
2422        assert!(content.contains("secret"));
2423        // basic/v3 are defaults and should not add redundant keys
2424        assert!(!content.contains("auth_type"));
2425    }
2426
2427    #[test]
2428    fn write_profile_to_config_creates_named_pat_profile() {
2429        let dir = TempDir::new().unwrap();
2430        let path = dir.path().join("config.toml");
2431
2432        write_profile_to_config(
2433            &path,
2434            "dc",
2435            ProfileWrite {
2436                host: "jira.corp.com",
2437                email: None,
2438                token: "pattoken",
2439                credential_store: "file",
2440                cloud_id: None,
2441                token_kind: "classic",
2442                expires_at: None,
2443                auth_type: "pat",
2444                api_version: 2,
2445                read_only: true,
2446            },
2447        )
2448        .unwrap();
2449
2450        let content = std::fs::read_to_string(&path).unwrap();
2451        assert!(content.contains("[profiles.dc]"));
2452        assert!(content.contains("jira.corp.com"));
2453        assert!(content.contains("pattoken"));
2454        assert!(content.contains("auth_type"));
2455        assert!(content.contains("api_version"));
2456        assert!(content.contains("read_only = true"));
2457        assert!(!content.contains("email"));
2458    }
2459
2460    #[test]
2461    fn a_profiles_key_that_is_not_a_table_is_reported_rather_than_panicked_on() {
2462        let dir = TempDir::new().unwrap();
2463        let path = dir.path().join("config.toml");
2464        std::fs::write(&path, "profiles = 5\n").unwrap();
2465
2466        let err = write_profile_to_config(
2467            &path,
2468            "work",
2469            ProfileWrite {
2470                host: "h.atlassian.net",
2471                email: None,
2472                token: "tok",
2473                credential_store: "file",
2474                cloud_id: None,
2475                token_kind: "classic",
2476                expires_at: None,
2477                auth_type: "basic",
2478                api_version: 3,
2479                read_only: false,
2480            },
2481        )
2482        .expect_err("a `profiles` integer cannot hold a profile")
2483        .to_string();
2484        assert!(
2485            err.contains("profiles") && err.contains("work"),
2486            "the message must name the key and the profile being added: {err}"
2487        );
2488        assert!(
2489            err.contains(&path.display().to_string()),
2490            "the message must name the file to edit: {err}"
2491        );
2492
2493        // Control: the same call against a well-formed `profiles` table has to
2494        // succeed, or the check above would pass by refusing everything.
2495        let good = dir.path().join("good.toml");
2496        std::fs::write(&good, "[profiles.other]\nhost = \"a.b\"\n").unwrap();
2497        write_profile_to_config(
2498            &good,
2499            "work",
2500            ProfileWrite {
2501                host: "h.atlassian.net",
2502                email: None,
2503                token: "tok",
2504                credential_store: "file",
2505                cloud_id: None,
2506                token_kind: "classic",
2507                expires_at: None,
2508                auth_type: "basic",
2509                api_version: 3,
2510                read_only: false,
2511            },
2512        )
2513        .unwrap();
2514        let written = std::fs::read_to_string(&good).unwrap();
2515        assert!(written.contains("[profiles.work]") && written.contains("[profiles.other]"));
2516    }
2517
2518    #[test]
2519    fn write_profile_to_config_preserves_other_profiles() {
2520        let dir = TempDir::new().unwrap();
2521        let path = dir.path().join("config.toml");
2522
2523        // Write initial config with a default profile
2524        std::fs::write(
2525            &path,
2526            "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
2527        )
2528        .unwrap();
2529
2530        // Add a second named profile without touching default
2531        write_profile_to_config(
2532            &path,
2533            "work",
2534            ProfileWrite {
2535                host: "work.atlassian.net",
2536                email: Some("w@work.com"),
2537                token: "tok2",
2538                credential_store: "file",
2539                cloud_id: None,
2540                token_kind: "classic",
2541                expires_at: None,
2542                auth_type: "basic",
2543                api_version: 3,
2544                read_only: false,
2545            },
2546        )
2547        .unwrap();
2548
2549        let content = std::fs::read_to_string(&path).unwrap();
2550        assert!(
2551            content.contains("first.atlassian.net"),
2552            "default profile must be preserved"
2553        );
2554        assert!(
2555            content.contains("work.atlassian.net"),
2556            "new profile must be written"
2557        );
2558    }
2559
2560    // ── remove_profile ─────────────────────────────────────────────────────────
2561
2562    #[test]
2563    fn remove_profile_removes_default_section() {
2564        let _env = ProcessEnvLock::acquire().unwrap();
2565        let dir = TempDir::new().unwrap();
2566        let path = write_config(
2567            dir.path(),
2568            "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
2569        )
2570        .unwrap();
2571
2572        let _config_dir = set_config_dir_env(dir.path());
2573        remove_profile(&OutputConfig::new(true, false, true), "default").unwrap();
2574
2575        let content = std::fs::read_to_string(&path).unwrap();
2576        assert!(!content.contains("[default]"));
2577        assert!(!content.contains("acme.atlassian.net"));
2578    }
2579
2580    #[test]
2581    fn remove_profile_removes_named_profile_preserves_others() {
2582        let _env = ProcessEnvLock::acquire().unwrap();
2583        let dir = TempDir::new().unwrap();
2584        let path = write_config(
2585            dir.path(),
2586            "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
2587             [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
2588        )
2589        .unwrap();
2590
2591        let _config_dir = set_config_dir_env(dir.path());
2592        remove_profile(&OutputConfig::new(true, false, true), "work").unwrap();
2593
2594        let content = std::fs::read_to_string(&path).unwrap();
2595        assert!(
2596            !content.contains("work.atlassian.net"),
2597            "work profile must be gone"
2598        );
2599        assert!(
2600            content.contains("first.atlassian.net"),
2601            "default profile must be preserved"
2602        );
2603    }
2604
2605    #[test]
2606    fn remove_profile_last_named_profile_leaves_default_intact() {
2607        let _env = ProcessEnvLock::acquire().unwrap();
2608        let dir = TempDir::new().unwrap();
2609        let path = write_config(
2610            dir.path(),
2611            "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
2612             [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
2613        )
2614        .unwrap();
2615
2616        let _config_dir = set_config_dir_env(dir.path());
2617        remove_profile(&OutputConfig::new(true, false, true), "staging").unwrap();
2618
2619        let content = std::fs::read_to_string(&path).unwrap();
2620        assert!(
2621            !content.contains("staging.atlassian.net"),
2622            "staging must be gone"
2623        );
2624        assert!(
2625            content.contains("acme.atlassian.net"),
2626            "default must be preserved"
2627        );
2628    }
2629
2630    // ── dc_pat_url ─────────────────────────────────────────────────────────────
2631
2632    #[test]
2633    fn dc_pat_url_without_host_returns_placeholder() {
2634        let url = dc_pat_url(None);
2635        assert!(url.starts_with("http://<your-host>"));
2636        assert!(url.ends_with(PAT_PATH));
2637    }
2638
2639    #[test]
2640    fn dc_pat_url_bare_host_adds_https_scheme() {
2641        let url = dc_pat_url(Some("jira.corp.com"));
2642        assert!(url.starts_with("https://jira.corp.com"));
2643        assert!(url.ends_with(PAT_PATH));
2644    }
2645
2646    #[test]
2647    fn dc_pat_url_host_with_https_scheme_is_preserved() {
2648        let url = dc_pat_url(Some("https://jira.corp.com/"));
2649        assert!(url.starts_with("https://jira.corp.com"));
2650        assert!(!url.contains("https://https://"));
2651        assert!(url.ends_with(PAT_PATH));
2652    }
2653
2654    #[test]
2655    fn dc_pat_url_host_with_http_scheme_is_preserved() {
2656        let url = dc_pat_url(Some("http://localhost:8080"));
2657        assert!(url.starts_with("http://localhost:8080"));
2658        assert!(url.ends_with(PAT_PATH));
2659    }
2660}