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        // A hand-edited config can carry `profiles` as a string or a number.
753        // Reporting that is the whole job here: panicking loses the reason, and
754        // replacing the value would delete whatever the user meant by it.
755        let profiles = profiles.as_table_mut().ok_or_else(|| {
756            format!(
757                "{} defines `profiles` as something other than a table, so the `{profile_name}` profile cannot be added to it",
758                path.display()
759            )
760        })?;
761        profiles.insert(profile_name.to_owned(), toml::Value::Table(section));
762    }
763
764    if let Some(parent) = path.parent() {
765        std::fs::create_dir_all(parent)?;
766    }
767    std::fs::write(path, toml::to_string_pretty(&doc)?)?;
768
769    Ok(())
770}
771
772/// Remove a named profile from the config file.
773///
774/// The "default" profile is removed by deleting the `[default]` section. Named profiles
775/// are removed from the `[profiles]` table. Prints a success or error message; does not
776/// write to stdout so it is safe in JSON mode.
777pub fn remove_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
778    let path = config_path();
779
780    if !path.exists() {
781        return Err(ApiError::NotFound(format!(
782            "config file at {}",
783            path.display()
784        )));
785    }
786
787    let content = std::fs::read_to_string(&path)
788        .map_err(|e| ApiError::Other(format!("Failed to read config: {e}")))?;
789    let mut doc: toml::Value = toml::from_str(&content)
790        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
791    let root = doc
792        .as_table_mut()
793        .ok_or_else(|| ApiError::Other("config is not a TOML table".to_string()))?;
794
795    let removed = if profile_name == "default" {
796        root.remove("default").is_some()
797    } else {
798        root.get_mut("profiles")
799            .and_then(toml::Value::as_table_mut)
800            .and_then(|t| t.remove(profile_name))
801            .is_some()
802    };
803
804    if !removed {
805        return Err(ApiError::NotFound(format!(
806            "profile '{profile_name}' in config. Available: {}",
807            format_available(&removable_profiles(root))
808        )));
809    }
810
811    let serialized = toml::to_string_pretty(&doc)
812        .map_err(|e| ApiError::Other(format!("Failed to serialize config: {e}")))?;
813    std::fs::write(&path, serialized)
814        .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
815
816    out.print_result(
817        &serde_json::json!({ "profile": profile_name, "removed": true }),
818        &format!("{} Removed profile '{profile_name}'", sym_ok()),
819    );
820    Ok(())
821}
822
823/// Names `config remove` accepts, in deterministic order: the `default`
824/// section when present, then each `[profiles.*]` key.
825fn removable_profiles(root: &toml::Table) -> Vec<&str> {
826    let mut names: Vec<&str> = Vec::new();
827    if root.contains_key("default") {
828        names.push("default");
829    }
830    if let Some(profiles) = root.get("profiles").and_then(toml::Value::as_table) {
831        names.extend(profiles.keys().map(String::as_str));
832    }
833    names
834}
835
836const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
837
838/// Build the Personal Access Token creation URL for a Jira DC/Server instance.
839///
840/// When `host` is known the full URL is returned so the user can click it directly.
841/// When unknown a placeholder template is returned.
842fn dc_pat_url(host: Option<&str>) -> String {
843    match host {
844        Some(h) => {
845            let base = if h.starts_with("http://") || h.starts_with("https://") {
846                h.trim_end_matches('/').to_string()
847            } else {
848                format!("https://{}", h.trim_end_matches('/'))
849            };
850            format!("{base}{PAT_PATH}")
851        }
852        None => format!("http://<your-host>{PAT_PATH}"),
853    }
854}
855
856/// Mask a token for display, showing only the last 4 characters.
857///
858/// Atlassian tokens begin with a predictable prefix, so showing the
859/// start provides no meaningful identification - the end is more useful.
860fn mask_token(token: &str) -> String {
861    let n = token.chars().count();
862    if n > 4 {
863        let suffix: String = token.chars().skip(n - 4).collect();
864        format!("***{suffix}")
865    } else {
866        "***".into()
867    }
868}
869
870fn env_var(name: &str) -> Option<String> {
871    std::env::var(name)
872        .ok()
873        .and_then(|value| normalize_value(Some(value)))
874}
875
876/// The values every boolean environment variable in this CLI reads as on and
877/// off, matched case-insensitively.
878///
879/// Public because `jira schema` declares them: an agent should not have to guess
880/// which spellings the safety switch accepts.
881pub const TRUTHY: &[&str] = &["1", "true", "yes", "on"];
882pub const FALSY: &[&str] = &["0", "false", "no", "off"];
883
884/// Whether a diagnostics-only toggle is switched on.
885///
886/// An unrecognised value means off here, because failing a command outright over
887/// a typo in a debug switch costs more than the missed logging. Safety switches
888/// use `parse_read_only` instead, which refuses.
889pub fn is_truthy(value: &str) -> bool {
890    TRUTHY.contains(&value.trim().to_ascii_lowercase().as_str())
891}
892
893/// Parse `JIRA_READ_ONLY`, rejecting anything that is neither an on nor an off
894/// value.
895///
896/// The guard is a safety control, so an unrecognised value must not resolve to
897/// "off": `JIRA_READ_ONLY=enabled` would then read as protection while every
898/// write went through. Refusing to start is the only answer that cannot be
899/// mistaken for the setting having worked.
900fn parse_read_only(value: &str) -> Result<bool, ApiError> {
901    let v = value.to_ascii_lowercase();
902    if TRUTHY.contains(&v.as_str()) {
903        Ok(true)
904    } else if FALSY.contains(&v.as_str()) {
905        Ok(false)
906    } else {
907        Err(ApiError::InvalidInput(format!(
908            "JIRA_READ_ONLY is set to '{value}', which is neither on ({}) nor off ({}). \
909             Refusing to run rather than guess whether writes are meant to be blocked.",
910            TRUTHY.join(", "),
911            FALSY.join(", ")
912        )))
913    }
914}
915
916/// Parse an `auth_type` from the environment or the config file.
917///
918/// A typo must not fall back to basic auth: on a Data Center instance that turns
919/// "you misspelled pat" into an opaque 401 from Jira.
920fn parse_auth_type(value: &str) -> Result<AuthType, ApiError> {
921    if value.eq_ignore_ascii_case("basic") {
922        Ok(AuthType::Basic)
923    } else if value.eq_ignore_ascii_case("pat") {
924        Ok(AuthType::Pat)
925    } else {
926        Err(ApiError::InvalidInput(format!(
927            "auth_type '{value}' is not recognised. Use 'basic' (Jira Cloud) or \
928             'pat' (Jira Data Center/Server)."
929        )))
930    }
931}
932
933/// Jira REST API versions this CLI knows how to talk to.
934const API_VERSIONS: &[u8] = &[2, 3];
935
936fn parse_api_version(value: &str) -> Result<u8, ApiError> {
937    let parsed = value.parse::<u8>().map_err(|_| {
938        ApiError::InvalidInput(format!(
939            "api_version '{value}' is not a number. Use 3 (Jira Cloud) or 2 \
940             (Jira Data Center/Server)."
941        ))
942    })?;
943    validate_api_version(parsed)
944}
945
946/// Reject a version the client has no URL scheme for, rather than building
947/// requests against `/rest/api/<n>/` and reporting Jira's 404 as the problem.
948fn validate_api_version(version: u8) -> Result<u8, ApiError> {
949    if API_VERSIONS.contains(&version) {
950        Ok(version)
951    } else {
952        Err(ApiError::InvalidInput(format!(
953            "api_version {version} is not supported. Use 3 (Jira Cloud) or 2 \
954             (Jira Data Center/Server)."
955        )))
956    }
957}
958
959fn normalize_value(value: Option<String>) -> Option<String> {
960    value.and_then(|value| {
961        let trimmed = value.trim();
962        if trimmed.is_empty() {
963            None
964        } else {
965            Some(trimmed.to_string())
966        }
967    })
968}
969
970fn normalize_str(value: Option<&str>) -> Option<&str> {
971    value.and_then(|value| {
972        let trimmed = value.trim();
973        if trimmed.is_empty() {
974            None
975        } else {
976            Some(trimmed)
977        }
978    })
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
985    use tempfile::TempDir;
986
987    #[test]
988    fn mask_token_long() {
989        let masked = mask_token("ATATxxx1234abcd");
990        assert!(masked.starts_with("***"));
991        assert!(masked.ends_with("abcd"));
992    }
993
994    #[test]
995    fn read_only_accepts_its_documented_values_in_any_case() {
996        for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "On"] {
997            assert!(parse_read_only(on).unwrap(), "{on} should enable the guard");
998        }
999        for off in ["0", "false", "FALSE", "no", "No", "off", "OFF"] {
1000            assert!(
1001                !parse_read_only(off).unwrap(),
1002                "{off} should disable the guard"
1003            );
1004        }
1005    }
1006
1007    /// The dangerous direction: a value nobody recognises must not quietly mean
1008    /// "writes allowed", because the operator who set it believes the opposite.
1009    #[test]
1010    fn read_only_refuses_a_value_it_does_not_understand() {
1011        for bad in ["enabled", "ture", "2", "y", "readonly"] {
1012            let err = parse_read_only(bad).unwrap_err();
1013            let message = err.to_string();
1014            assert!(
1015                message.contains(bad),
1016                "the rejection must quote the offending value; got: {message}"
1017            );
1018            assert!(
1019                matches!(err, ApiError::InvalidInput(_)),
1020                "{bad} must be reported as bad input, not as a Jira failure"
1021            );
1022        }
1023    }
1024
1025    /// A diagnostics switch reads an unknown value as off, which is the opposite
1026    /// policy from the read-only guard above and deliberately so.
1027    #[test]
1028    fn is_truthy_accepts_any_case_and_treats_the_unknown_as_off() {
1029        for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "  on  "] {
1030            assert!(is_truthy(on), "{on} should read as on");
1031        }
1032        for off in ["0", "false", "no", "off", "enabled", "ture", ""] {
1033            assert!(!is_truthy(off), "{off} should read as off");
1034        }
1035    }
1036
1037    /// A blank `auth_type` is an unset one, not a typo to refuse. Otherwise a
1038    /// config file with an empty placeholder stops every command.
1039    #[test]
1040    fn load_blank_auth_type_in_the_config_file_is_treated_as_unset() {
1041        let _lock = ProcessEnvLock::acquire();
1042        let dir = TempDir::new().unwrap();
1043        write_config(
1044            dir.path(),
1045            "[default]\nhost = \"x.atlassian.net\"\nemail = \"me@example.com\"\n\
1046             token = \"t\"\nauth_type = \"  \"\n",
1047        )
1048        .unwrap();
1049        let _config_dir = set_config_dir_env(dir.path());
1050        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1051        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1052
1053        let config = Config::load(None, None, None).unwrap();
1054        assert_eq!(config.auth_type, AuthType::Basic);
1055    }
1056
1057    #[test]
1058    fn auth_type_refuses_a_typo_rather_than_falling_back_to_basic() {
1059        assert_eq!(parse_auth_type("pat").unwrap(), AuthType::Pat);
1060        assert_eq!(parse_auth_type("PAT").unwrap(), AuthType::Pat);
1061        assert_eq!(parse_auth_type("basic").unwrap(), AuthType::Basic);
1062
1063        let err = parse_auth_type("ptt").unwrap_err().to_string();
1064        assert!(err.contains("ptt"), "got: {err}");
1065        assert!(
1066            err.contains("pat"),
1067            "the message must name the real spelling"
1068        );
1069    }
1070
1071    #[test]
1072    fn api_version_refuses_anything_the_client_cannot_address() {
1073        assert_eq!(parse_api_version("2").unwrap(), 2);
1074        assert_eq!(parse_api_version("3").unwrap(), 3);
1075
1076        for bad in ["v3", "", "3.0", "latest"] {
1077            assert!(
1078                parse_api_version(bad).is_err(),
1079                "{bad} is not a version number"
1080            );
1081        }
1082        // Parses as a u8 and is still wrong: there is no /rest/api/7/.
1083        let err = parse_api_version("7").unwrap_err().to_string();
1084        assert!(err.contains('7'), "got: {err}");
1085    }
1086
1087    #[test]
1088    fn mask_token_short() {
1089        assert_eq!(mask_token("abc"), "***");
1090    }
1091
1092    #[test]
1093    fn mask_token_unicode_safe() {
1094        // Ensure char-based indexing doesn't panic on multi-byte chars
1095        let token = "token-日本語-end";
1096        let result = mask_token(token);
1097        assert!(result.starts_with("***"));
1098    }
1099
1100    #[test]
1101    #[cfg(not(target_os = "windows"))]
1102    fn config_path_prefers_xdg_config_home() {
1103        let _env = ProcessEnvLock::acquire().unwrap();
1104        let dir = TempDir::new().unwrap();
1105        let _config_dir = set_config_dir_env(dir.path());
1106
1107        assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
1108    }
1109
1110    #[test]
1111    fn load_ignores_blank_env_vars_and_falls_back_to_file() {
1112        let _env = ProcessEnvLock::acquire().unwrap();
1113        let dir = TempDir::new().unwrap();
1114        write_config(
1115            dir.path(),
1116            r#"
1117[default]
1118host = "work.atlassian.net"
1119email = "me@example.com"
1120token = "secret-token"
1121"#,
1122        )
1123        .unwrap();
1124
1125        let _config_dir = set_config_dir_env(dir.path());
1126        let _host = EnvVarGuard::set("JIRA_HOST", "   ");
1127        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1128        let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
1129        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1130
1131        let cfg = Config::load(None, None, None).unwrap();
1132        assert_eq!(cfg.host, "work.atlassian.net");
1133        assert_eq!(cfg.email, "me@example.com");
1134        assert_eq!(cfg.token, "secret-token");
1135    }
1136
1137    #[test]
1138    fn load_accepts_documented_default_section() {
1139        let _env = ProcessEnvLock::acquire().unwrap();
1140        let dir = TempDir::new().unwrap();
1141        write_config(
1142            dir.path(),
1143            r#"
1144[default]
1145host = "example.atlassian.net"
1146email = "me@example.com"
1147token = "secret-token"
1148"#,
1149        )
1150        .unwrap();
1151
1152        let _config_dir = set_config_dir_env(dir.path());
1153        let _host = EnvVarGuard::unset("JIRA_HOST");
1154        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1155        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1156        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1157
1158        let cfg = Config::load(None, None, None).unwrap();
1159        assert_eq!(cfg.host, "example.atlassian.net");
1160        assert_eq!(cfg.email, "me@example.com");
1161        assert_eq!(cfg.token, "secret-token");
1162    }
1163
1164    #[test]
1165    fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
1166        let _env = ProcessEnvLock::acquire().unwrap();
1167        let dir = TempDir::new().unwrap();
1168        let _config_dir = set_config_dir_env(dir.path());
1169        let _host = EnvVarGuard::set("JIRA_HOST", "");
1170        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1171        let _token = EnvVarGuard::set("JIRA_TOKEN", "");
1172        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1173
1174        let err = Config::load(None, None, None).unwrap_err();
1175        assert!(matches!(err, ApiError::InvalidInput(_)));
1176        assert!(err.to_string().contains("No Jira host configured"));
1177    }
1178
1179    #[test]
1180    fn permission_guidance_matches_platform() {
1181        let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
1182
1183        #[cfg(target_os = "windows")]
1184        assert!(guidance.contains("AppData"));
1185
1186        #[cfg(not(target_os = "windows"))]
1187        assert!(guidance.starts_with("chmod 600 "));
1188    }
1189
1190    // ── Priority: CLI > env > file ─────────────────────────────────────────────
1191
1192    #[test]
1193    fn load_env_host_overrides_file() {
1194        let _env = ProcessEnvLock::acquire().unwrap();
1195        let dir = TempDir::new().unwrap();
1196        write_config(
1197            dir.path(),
1198            r#"
1199[default]
1200host = "file.atlassian.net"
1201email = "me@example.com"
1202token = "tok"
1203"#,
1204        )
1205        .unwrap();
1206
1207        let _config_dir = set_config_dir_env(dir.path());
1208        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1209        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1210        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1211        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1212
1213        let cfg = Config::load(None, None, None).unwrap();
1214        assert_eq!(cfg.host, "env.atlassian.net");
1215    }
1216
1217    #[test]
1218    fn load_cli_host_arg_overrides_env_and_file() {
1219        let _env = ProcessEnvLock::acquire().unwrap();
1220        let dir = TempDir::new().unwrap();
1221        write_config(
1222            dir.path(),
1223            r#"
1224[default]
1225host = "file.atlassian.net"
1226email = "me@example.com"
1227token = "tok"
1228"#,
1229        )
1230        .unwrap();
1231
1232        let _config_dir = set_config_dir_env(dir.path());
1233        let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1234        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1235        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1236        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1237
1238        let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
1239        assert_eq!(cfg.host, "cli.atlassian.net");
1240    }
1241
1242    // ── Error cases ────────────────────────────────────────────────────────────
1243
1244    #[test]
1245    fn load_missing_token_returns_error() {
1246        let _env = ProcessEnvLock::acquire().unwrap();
1247        let dir = TempDir::new().unwrap();
1248        let _config_dir = set_config_dir_env(dir.path());
1249        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1250        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1251        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1252        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1253
1254        let err = Config::load(None, None, None).unwrap_err();
1255        assert!(matches!(err, ApiError::InvalidInput(_)));
1256        assert!(err.to_string().contains("No API token"));
1257    }
1258
1259    #[test]
1260    fn load_missing_email_for_basic_auth_returns_error() {
1261        let _env = ProcessEnvLock::acquire().unwrap();
1262        let dir = TempDir::new().unwrap();
1263        let _config_dir = set_config_dir_env(dir.path());
1264        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1265        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1266        let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
1267        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1268        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1269
1270        let err = Config::load(None, None, None).unwrap_err();
1271        assert!(matches!(err, ApiError::InvalidInput(_)));
1272        assert!(err.to_string().contains("No email configured"));
1273    }
1274
1275    #[test]
1276    fn load_invalid_toml_returns_error() {
1277        let _env = ProcessEnvLock::acquire().unwrap();
1278        let dir = TempDir::new().unwrap();
1279        write_config(dir.path(), "host = [invalid toml").unwrap();
1280
1281        let _config_dir = set_config_dir_env(dir.path());
1282        let _host = EnvVarGuard::unset("JIRA_HOST");
1283        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1284        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1285
1286        let err = Config::load(None, None, None).unwrap_err();
1287        assert!(matches!(err, ApiError::Other(_)));
1288        assert!(err.to_string().contains("parse"));
1289    }
1290
1291    // ── Auth type ──────────────────────────────────────────────────────────────
1292
1293    #[test]
1294    fn load_pat_auth_does_not_require_email() {
1295        let _env = ProcessEnvLock::acquire().unwrap();
1296        let dir = TempDir::new().unwrap();
1297        write_config(
1298            dir.path(),
1299            r#"
1300[default]
1301host = "jira.corp.com"
1302token = "my-pat-token"
1303auth_type = "pat"
1304api_version = 2
1305"#,
1306        )
1307        .unwrap();
1308
1309        let _config_dir = set_config_dir_env(dir.path());
1310        let _host = EnvVarGuard::unset("JIRA_HOST");
1311        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1312        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1313        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1314        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1315
1316        let cfg = Config::load(None, None, None).unwrap();
1317        assert_eq!(cfg.auth_type, AuthType::Pat);
1318        assert_eq!(cfg.api_version, 2);
1319        assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
1320    }
1321
1322    #[test]
1323    fn load_jira_auth_type_env_pat_overrides_basic() {
1324        let _env = ProcessEnvLock::acquire().unwrap();
1325        let dir = TempDir::new().unwrap();
1326        write_config(
1327            dir.path(),
1328            r#"
1329[default]
1330host = "jira.corp.com"
1331email = "me@example.com"
1332token = "tok"
1333auth_type = "basic"
1334"#,
1335        )
1336        .unwrap();
1337
1338        let _config_dir = set_config_dir_env(dir.path());
1339        let _host = EnvVarGuard::unset("JIRA_HOST");
1340        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1341        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1342        let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
1343        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1344
1345        let cfg = Config::load(None, None, None).unwrap();
1346        assert_eq!(cfg.auth_type, AuthType::Pat);
1347    }
1348
1349    #[test]
1350    fn load_jira_api_version_env_overrides_default() {
1351        let _env = ProcessEnvLock::acquire().unwrap();
1352        let dir = TempDir::new().unwrap();
1353        let _config_dir = set_config_dir_env(dir.path());
1354        let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1355        let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1356        let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
1357        let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
1358        let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1359        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1360
1361        let cfg = Config::load(None, None, None).unwrap();
1362        assert_eq!(cfg.api_version, 2);
1363    }
1364
1365    // ── Profile selection ──────────────────────────────────────────────────────
1366
1367    #[test]
1368    fn load_profile_arg_selects_named_section() {
1369        let _env = ProcessEnvLock::acquire().unwrap();
1370        let dir = TempDir::new().unwrap();
1371        write_config(
1372            dir.path(),
1373            r#"
1374[default]
1375host = "default.atlassian.net"
1376email = "default@example.com"
1377token = "default-tok"
1378
1379[profiles.work]
1380host = "work.atlassian.net"
1381email = "me@work.com"
1382token = "work-tok"
1383"#,
1384        )
1385        .unwrap();
1386
1387        let _config_dir = set_config_dir_env(dir.path());
1388        let _host = EnvVarGuard::unset("JIRA_HOST");
1389        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1390        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1391        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1392
1393        let cfg = Config::load(None, None, Some("work".into())).unwrap();
1394        assert_eq!(cfg.host, "work.atlassian.net");
1395        assert_eq!(cfg.email, "me@work.com");
1396        assert_eq!(cfg.token, "work-tok");
1397    }
1398
1399    #[test]
1400    fn load_jira_profile_env_selects_named_section() {
1401        let _env = ProcessEnvLock::acquire().unwrap();
1402        let dir = TempDir::new().unwrap();
1403        write_config(
1404            dir.path(),
1405            r#"
1406[default]
1407host = "default.atlassian.net"
1408email = "default@example.com"
1409token = "default-tok"
1410
1411[profiles.staging]
1412host = "staging.atlassian.net"
1413email = "me@staging.com"
1414token = "staging-tok"
1415"#,
1416        )
1417        .unwrap();
1418
1419        let _config_dir = set_config_dir_env(dir.path());
1420        let _host = EnvVarGuard::unset("JIRA_HOST");
1421        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1422        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1423        let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
1424
1425        let cfg = Config::load(None, None, None).unwrap();
1426        assert_eq!(cfg.host, "staging.atlassian.net");
1427    }
1428
1429    #[test]
1430    fn load_unknown_profile_returns_descriptive_error() {
1431        let _env = ProcessEnvLock::acquire().unwrap();
1432        let dir = TempDir::new().unwrap();
1433        write_config(
1434            dir.path(),
1435            r#"
1436[profiles.alpha]
1437host = "alpha.atlassian.net"
1438email = "me@alpha.com"
1439token = "alpha-tok"
1440"#,
1441        )
1442        .unwrap();
1443
1444        let _config_dir = set_config_dir_env(dir.path());
1445        let _host = EnvVarGuard::unset("JIRA_HOST");
1446        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1447        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1448
1449        let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
1450        assert!(
1451            matches!(err, ApiError::NotFound(_)),
1452            "selecting a profile that is not in the config is a not_found condition, \
1453             not an unexpected error: {err:?}"
1454        );
1455        let msg = err.to_string();
1456        assert!(
1457            msg.contains("nonexistent"),
1458            "error should name the bad profile"
1459        );
1460        assert!(
1461            msg.contains("alpha"),
1462            "error should list available profiles"
1463        );
1464    }
1465
1466    // ── config::show ───────────────────────────────────────────────────────────
1467
1468    #[test]
1469    fn show_json_output_includes_host_and_masked_token() {
1470        let _env = ProcessEnvLock::acquire().unwrap();
1471        let dir = TempDir::new().unwrap();
1472        write_config(
1473            dir.path(),
1474            r#"
1475[default]
1476host = "show-test.atlassian.net"
1477email = "me@example.com"
1478token = "supersecrettoken"
1479"#,
1480        )
1481        .unwrap();
1482
1483        let _config_dir = set_config_dir_env(dir.path());
1484        let _host = EnvVarGuard::unset("JIRA_HOST");
1485        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1486        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1487        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1488
1489        let out = crate::output::OutputConfig::new(true, false, true);
1490        // Must not error and must produce no error output
1491        show(&out, None, None, None).unwrap();
1492    }
1493
1494    #[test]
1495    fn show_text_output_renders_without_error() {
1496        let _env = ProcessEnvLock::acquire().unwrap();
1497        let dir = TempDir::new().unwrap();
1498        write_config(
1499            dir.path(),
1500            r#"
1501[default]
1502host = "show-test.atlassian.net"
1503email = "me@example.com"
1504token = "supersecrettoken"
1505"#,
1506        )
1507        .unwrap();
1508
1509        let _config_dir = set_config_dir_env(dir.path());
1510        let _host = EnvVarGuard::unset("JIRA_HOST");
1511        let _email = EnvVarGuard::unset("JIRA_EMAIL");
1512        let _token = EnvVarGuard::unset("JIRA_TOKEN");
1513        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1514
1515        let out = crate::output::OutputConfig::new(false, false, true);
1516        show(&out, None, None, None).unwrap();
1517    }
1518
1519    // ── config::init ───────────────────────────────────────────────────────────
1520
1521    #[tokio::test]
1522    async fn init_json_output_includes_example_and_paths() {
1523        let out = crate::output::OutputConfig::new(true, false, true);
1524        // No env or config needed - init() never loads credentials in JSON mode
1525        init(&out, Some("jira.corp.com")).await;
1526    }
1527
1528    // The text path of init() requires an interactive TTY; in test context stdin is
1529    // not a TTY so it prints a short message and returns without hanging.
1530    #[tokio::test]
1531    async fn init_non_interactive_prints_message_without_error() {
1532        let out = crate::output::OutputConfig {
1533            json: false,
1534            quiet: false,
1535        };
1536        // stdin is not a TTY in tests - must return immediately, not hang
1537        init(&out, None).await;
1538    }
1539
1540    #[test]
1541    fn write_profile_to_config_creates_default_profile() {
1542        let dir = TempDir::new().unwrap();
1543        let path = dir.path().join("jira").join("config.toml");
1544
1545        write_profile_to_config(
1546            &path,
1547            "default",
1548            "acme.atlassian.net",
1549            Some("me@acme.com"),
1550            "secret",
1551            "basic",
1552            3,
1553        )
1554        .unwrap();
1555
1556        let content = std::fs::read_to_string(&path).unwrap();
1557        assert!(content.contains("acme.atlassian.net"));
1558        assert!(content.contains("me@acme.com"));
1559        assert!(content.contains("secret"));
1560        // basic/v3 are defaults and should not add redundant keys
1561        assert!(!content.contains("auth_type"));
1562    }
1563
1564    #[test]
1565    fn write_profile_to_config_creates_named_pat_profile() {
1566        let dir = TempDir::new().unwrap();
1567        let path = dir.path().join("config.toml");
1568
1569        write_profile_to_config(&path, "dc", "jira.corp.com", None, "pattoken", "pat", 2).unwrap();
1570
1571        let content = std::fs::read_to_string(&path).unwrap();
1572        assert!(content.contains("[profiles.dc]"));
1573        assert!(content.contains("jira.corp.com"));
1574        assert!(content.contains("pattoken"));
1575        assert!(content.contains("auth_type"));
1576        assert!(content.contains("api_version"));
1577        assert!(!content.contains("email"));
1578    }
1579
1580    #[test]
1581    fn a_profiles_key_that_is_not_a_table_is_reported_rather_than_panicked_on() {
1582        let dir = TempDir::new().unwrap();
1583        let path = dir.path().join("config.toml");
1584        std::fs::write(&path, "profiles = 5\n").unwrap();
1585
1586        let err =
1587            write_profile_to_config(&path, "work", "h.atlassian.net", None, "tok", "basic", 3)
1588                .expect_err("a `profiles` integer cannot hold a profile")
1589                .to_string();
1590        assert!(
1591            err.contains("profiles") && err.contains("work"),
1592            "the message must name the key and the profile being added: {err}"
1593        );
1594        assert!(
1595            err.contains(&path.display().to_string()),
1596            "the message must name the file to edit: {err}"
1597        );
1598
1599        // Control: the same call against a well-formed `profiles` table has to
1600        // succeed, or the check above would pass by refusing everything.
1601        let good = dir.path().join("good.toml");
1602        std::fs::write(&good, "[profiles.other]\nhost = \"a.b\"\n").unwrap();
1603        write_profile_to_config(&good, "work", "h.atlassian.net", None, "tok", "basic", 3).unwrap();
1604        let written = std::fs::read_to_string(&good).unwrap();
1605        assert!(written.contains("[profiles.work]") && written.contains("[profiles.other]"));
1606    }
1607
1608    #[test]
1609    fn write_profile_to_config_preserves_other_profiles() {
1610        let dir = TempDir::new().unwrap();
1611        let path = dir.path().join("config.toml");
1612
1613        // Write initial config with a default profile
1614        std::fs::write(
1615            &path,
1616            "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
1617        )
1618        .unwrap();
1619
1620        // Add a second named profile without touching default
1621        write_profile_to_config(
1622            &path,
1623            "work",
1624            "work.atlassian.net",
1625            Some("w@work.com"),
1626            "tok2",
1627            "basic",
1628            3,
1629        )
1630        .unwrap();
1631
1632        let content = std::fs::read_to_string(&path).unwrap();
1633        assert!(
1634            content.contains("first.atlassian.net"),
1635            "default profile must be preserved"
1636        );
1637        assert!(
1638            content.contains("work.atlassian.net"),
1639            "new profile must be written"
1640        );
1641    }
1642
1643    // ── remove_profile ─────────────────────────────────────────────────────────
1644
1645    #[test]
1646    fn remove_profile_removes_default_section() {
1647        let _env = ProcessEnvLock::acquire().unwrap();
1648        let dir = TempDir::new().unwrap();
1649        let path = write_config(
1650            dir.path(),
1651            "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
1652        )
1653        .unwrap();
1654
1655        let _config_dir = set_config_dir_env(dir.path());
1656        remove_profile(&OutputConfig::new(true, false, true), "default").unwrap();
1657
1658        let content = std::fs::read_to_string(&path).unwrap();
1659        assert!(!content.contains("[default]"));
1660        assert!(!content.contains("acme.atlassian.net"));
1661    }
1662
1663    #[test]
1664    fn remove_profile_removes_named_profile_preserves_others() {
1665        let _env = ProcessEnvLock::acquire().unwrap();
1666        let dir = TempDir::new().unwrap();
1667        let path = write_config(
1668            dir.path(),
1669            "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
1670             [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
1671        )
1672        .unwrap();
1673
1674        let _config_dir = set_config_dir_env(dir.path());
1675        remove_profile(&OutputConfig::new(true, false, true), "work").unwrap();
1676
1677        let content = std::fs::read_to_string(&path).unwrap();
1678        assert!(
1679            !content.contains("work.atlassian.net"),
1680            "work profile must be gone"
1681        );
1682        assert!(
1683            content.contains("first.atlassian.net"),
1684            "default profile must be preserved"
1685        );
1686    }
1687
1688    #[test]
1689    fn remove_profile_last_named_profile_leaves_default_intact() {
1690        let _env = ProcessEnvLock::acquire().unwrap();
1691        let dir = TempDir::new().unwrap();
1692        let path = write_config(
1693            dir.path(),
1694            "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
1695             [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
1696        )
1697        .unwrap();
1698
1699        let _config_dir = set_config_dir_env(dir.path());
1700        remove_profile(&OutputConfig::new(true, false, true), "staging").unwrap();
1701
1702        let content = std::fs::read_to_string(&path).unwrap();
1703        assert!(
1704            !content.contains("staging.atlassian.net"),
1705            "staging must be gone"
1706        );
1707        assert!(
1708            content.contains("acme.atlassian.net"),
1709            "default must be preserved"
1710        );
1711    }
1712
1713    // ── dc_pat_url ─────────────────────────────────────────────────────────────
1714
1715    #[test]
1716    fn dc_pat_url_without_host_returns_placeholder() {
1717        let url = dc_pat_url(None);
1718        assert!(url.starts_with("http://<your-host>"));
1719        assert!(url.contains(PAT_PATH));
1720    }
1721
1722    #[test]
1723    fn dc_pat_url_bare_host_adds_https_scheme() {
1724        let url = dc_pat_url(Some("jira.corp.com"));
1725        assert!(url.starts_with("https://jira.corp.com"));
1726        assert!(url.contains(PAT_PATH));
1727    }
1728
1729    #[test]
1730    fn dc_pat_url_host_with_https_scheme_is_preserved() {
1731        let url = dc_pat_url(Some("https://jira.corp.com/"));
1732        assert!(url.starts_with("https://jira.corp.com"));
1733        assert!(!url.contains("https://https://"));
1734        assert!(url.contains(PAT_PATH));
1735    }
1736
1737    #[test]
1738    fn dc_pat_url_host_with_http_scheme_is_preserved() {
1739        let url = dc_pat_url(Some("http://localhost:8080"));
1740        assert!(url.starts_with("http://localhost:8080"));
1741        assert!(url.contains(PAT_PATH));
1742    }
1743}