Skip to main content

jira_cli/
config.rs

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