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;
7
8#[derive(Debug, Deserialize, Default, Clone)]
9pub struct ProfileConfig {
10    pub host: Option<String>,
11    pub email: Option<String>,
12    pub token: Option<String>,
13}
14
15#[derive(Debug, Deserialize, Default)]
16struct RawConfig {
17    #[serde(flatten)]
18    default: ProfileConfig,
19    #[serde(default)]
20    profiles: BTreeMap<String, ProfileConfig>,
21}
22
23/// Resolved credentials for a single profile.
24#[derive(Debug, Clone)]
25pub struct Config {
26    pub host: String,
27    pub email: String,
28    pub token: String,
29}
30
31impl Config {
32    /// Load config with priority: CLI args > env vars > config file.
33    ///
34    /// The API token must be supplied via the `JIRA_TOKEN` environment variable
35    /// or the config file — not via a CLI flag, to avoid leaking it in process
36    /// argument lists visible to other users.
37    pub fn load(
38        host_arg: Option<String>,
39        email_arg: Option<String>,
40        profile_arg: Option<String>,
41    ) -> Result<Self, ApiError> {
42        let file_profile = load_file_profile(profile_arg.as_deref())?;
43
44        let host = host_arg
45            .or_else(|| std::env::var("JIRA_HOST").ok())
46            .or(file_profile.host)
47            .ok_or_else(|| {
48                ApiError::InvalidInput(
49                    "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
50                )
51            })?;
52
53        let email = email_arg
54            .or_else(|| std::env::var("JIRA_EMAIL").ok())
55            .or(file_profile.email)
56            .ok_or_else(|| {
57                ApiError::InvalidInput(
58                    "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
59                )
60            })?;
61
62        let token = std::env::var("JIRA_TOKEN")
63            .ok()
64            .or(file_profile.token)
65            .ok_or_else(|| {
66                ApiError::InvalidInput(
67                    "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
68                )
69            })?;
70
71        Ok(Self { host, email, token })
72    }
73}
74
75fn config_path() -> PathBuf {
76    dirs::config_dir()
77        .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
78        .unwrap_or_else(|| PathBuf::from(".config"))
79        .join("jira")
80        .join("config.toml")
81}
82
83fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
84    let path = config_path();
85    let content = match std::fs::read_to_string(&path) {
86        Ok(c) => c,
87        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
88        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
89    };
90
91    let raw: RawConfig = toml::from_str(&content)
92        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
93
94    let profile_name = profile
95        .map(String::from)
96        .or_else(|| std::env::var("JIRA_PROFILE").ok());
97
98    match profile_name {
99        Some(name) => {
100            // BTreeMap gives sorted, deterministic output in error messages
101            let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
102            raw.profiles.get(&name).cloned().ok_or_else(|| {
103                ApiError::Other(format!(
104                    "Profile '{name}' not found in config. Available: {}",
105                    available.join(", ")
106                ))
107            })
108        }
109        None => Ok(raw.default),
110    }
111}
112
113/// Print the config file path and current resolved values (masking the token).
114pub fn show(host_arg: Option<String>, email_arg: Option<String>, profile_arg: Option<String>) {
115    let path = config_path();
116    eprintln!("Config file: {}", path.display());
117
118    match Config::load(host_arg, email_arg, profile_arg) {
119        Ok(cfg) => {
120            let masked = mask_token(&cfg.token);
121            println!("host:  {}", cfg.host);
122            println!("email: {}", cfg.email);
123            println!("token: {masked}");
124        }
125        Err(e) => {
126            eprintln!("Config error: {e}");
127        }
128    }
129}
130
131/// Print example config file and instructions for obtaining an API token.
132pub fn init() {
133    let path = config_path();
134    println!("Create or edit: {}", path.display());
135    println!();
136    println!("Example config:");
137    println!();
138    println!("[default]");
139    println!("host  = \"mycompany.atlassian.net\"");
140    println!("email = \"me@example.com\"");
141    println!("token = \"your-api-token\"");
142    println!();
143    println!("# Optional named profiles:");
144    println!("# [profiles.work]");
145    println!("# host  = \"work.atlassian.net\"");
146    println!("# email = \"me@work.com\"");
147    println!("# token = \"work-token\"");
148    println!();
149    println!(
150        "Get your API token at: https://id.atlassian.com/manage-profile/security/api-tokens"
151    );
152    println!();
153    println!("Permissions: chmod 600 {}", path.display());
154}
155
156/// Mask a token for display, showing only the last 4 characters.
157///
158/// Atlassian tokens begin with a predictable prefix, so showing the
159/// start provides no meaningful identification — the end is more useful.
160fn mask_token(token: &str) -> String {
161    let n = token.chars().count();
162    if n > 4 {
163        let suffix: String = token.chars().skip(n - 4).collect();
164        format!("***{suffix}")
165    } else {
166        "***".into()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn mask_token_long() {
176        let masked = mask_token("ATATxxx1234abcd");
177        assert!(masked.starts_with("***"));
178        assert!(masked.ends_with("abcd"));
179    }
180
181    #[test]
182    fn mask_token_short() {
183        assert_eq!(mask_token("abc"), "***");
184    }
185
186    #[test]
187    fn mask_token_unicode_safe() {
188        // Ensure char-based indexing doesn't panic on multi-byte chars
189        let token = "token-日本語-end";
190        let result = mask_token(token);
191        assert!(result.starts_with("***"));
192    }
193}