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::output::OutputConfig;
8
9#[derive(Debug, Deserialize, Default, Clone)]
10pub struct ProfileConfig {
11    pub host: Option<String>,
12    pub email: Option<String>,
13    pub token: Option<String>,
14}
15
16#[derive(Debug, Deserialize, Default)]
17struct RawConfig {
18    #[serde(default)]
19    default: ProfileConfig,
20    #[serde(default)]
21    profiles: BTreeMap<String, ProfileConfig>,
22    host: Option<String>,
23    email: Option<String>,
24    token: Option<String>,
25}
26
27impl RawConfig {
28    fn default_profile(&self) -> ProfileConfig {
29        ProfileConfig {
30            host: self.default.host.clone().or_else(|| self.host.clone()),
31            email: self.default.email.clone().or_else(|| self.email.clone()),
32            token: self.default.token.clone().or_else(|| self.token.clone()),
33        }
34    }
35}
36
37/// Resolved credentials for a single profile.
38#[derive(Debug, Clone)]
39pub struct Config {
40    pub host: String,
41    pub email: String,
42    pub token: String,
43}
44
45impl Config {
46    /// Load config with priority: CLI args > env vars > config file.
47    ///
48    /// The API token must be supplied via the `JIRA_TOKEN` environment variable
49    /// or the config file — not via a CLI flag, to avoid leaking it in process
50    /// argument lists visible to other users.
51    pub fn load(
52        host_arg: Option<String>,
53        email_arg: Option<String>,
54        profile_arg: Option<String>,
55    ) -> Result<Self, ApiError> {
56        let file_profile = load_file_profile(profile_arg.as_deref())?;
57
58        let host = normalize_value(host_arg)
59            .or_else(|| env_var("JIRA_HOST"))
60            .or_else(|| normalize_value(file_profile.host))
61            .ok_or_else(|| {
62                ApiError::InvalidInput(
63                    "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
64                )
65            })?;
66
67        let email = normalize_value(email_arg)
68            .or_else(|| env_var("JIRA_EMAIL"))
69            .or_else(|| normalize_value(file_profile.email))
70            .ok_or_else(|| {
71                ApiError::InvalidInput(
72                    "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
73                )
74            })?;
75
76        let token = env_var("JIRA_TOKEN")
77            .or_else(|| normalize_value(file_profile.token))
78            .ok_or_else(|| {
79                ApiError::InvalidInput(
80                    "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
81                )
82            })?;
83
84        Ok(Self { host, email, token })
85    }
86}
87
88fn config_path() -> PathBuf {
89    config_dir()
90        .unwrap_or_else(|| PathBuf::from(".config"))
91        .join("jira")
92        .join("config.toml")
93}
94
95pub fn schema_config_path() -> String {
96    config_path().display().to_string()
97}
98
99pub fn schema_config_path_description() -> &'static str {
100    #[cfg(target_os = "windows")]
101    {
102        "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
103    }
104
105    #[cfg(not(target_os = "windows"))]
106    {
107        "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
108    }
109}
110
111pub fn recommended_permissions(path: &std::path::Path) -> String {
112    #[cfg(target_os = "windows")]
113    {
114        format!(
115            "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
116            path.display()
117        )
118    }
119
120    #[cfg(not(target_os = "windows"))]
121    {
122        format!("chmod 600 {}", path.display())
123    }
124}
125
126pub fn schema_recommended_permissions_example() -> &'static str {
127    #[cfg(target_os = "windows")]
128    {
129        "Keep the file in your per-user %APPDATA% directory and out of shared folders."
130    }
131
132    #[cfg(not(target_os = "windows"))]
133    {
134        "chmod 600 /path/to/config.toml"
135    }
136}
137
138fn config_dir() -> Option<PathBuf> {
139    #[cfg(target_os = "windows")]
140    {
141        dirs::config_dir()
142    }
143
144    #[cfg(not(target_os = "windows"))]
145    {
146        std::env::var_os("XDG_CONFIG_HOME")
147            .filter(|value| !value.is_empty())
148            .map(PathBuf::from)
149            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
150    }
151}
152
153fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
154    let path = config_path();
155    let content = match std::fs::read_to_string(&path) {
156        Ok(c) => c,
157        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
158        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
159    };
160
161    let raw: RawConfig = toml::from_str(&content)
162        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
163
164    let profile_name = normalize_str(profile)
165        .map(str::to_owned)
166        .or_else(|| env_var("JIRA_PROFILE"));
167
168    match profile_name {
169        Some(name) => {
170            // BTreeMap gives sorted, deterministic output in error messages
171            let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
172            raw.profiles.get(&name).cloned().ok_or_else(|| {
173                ApiError::Other(format!(
174                    "Profile '{name}' not found in config. Available: {}",
175                    available.join(", ")
176                ))
177            })
178        }
179        None => Ok(raw.default_profile()),
180    }
181}
182
183/// Print the config file path and current resolved values (masking the token).
184pub fn show(
185    out: &OutputConfig,
186    host_arg: Option<String>,
187    email_arg: Option<String>,
188    profile_arg: Option<String>,
189) -> Result<(), ApiError> {
190    let path = config_path();
191    let cfg = Config::load(host_arg, email_arg, profile_arg)?;
192    let masked = mask_token(&cfg.token);
193
194    if out.json {
195        out.print_data(
196            &serde_json::to_string_pretty(&serde_json::json!({
197                "configPath": path,
198                "host": cfg.host,
199                "email": cfg.email,
200                "tokenMasked": masked,
201            }))
202            .expect("failed to serialize JSON"),
203        );
204    } else {
205        out.print_message(&format!("Config file: {}", path.display()));
206        out.print_data(&format!(
207            "host:  {}\nemail: {}\ntoken: {masked}",
208            cfg.host, cfg.email
209        ));
210    }
211    Ok(())
212}
213
214/// Print example config file and instructions for obtaining an API token.
215pub fn init(out: &OutputConfig) {
216    let path = config_path();
217    let path_resolution = schema_config_path_description();
218    let permission_advice = recommended_permissions(&path);
219    let example = serde_json::json!({
220        "default": {
221            "host": "mycompany.atlassian.net",
222            "email": "me@example.com",
223            "token": "your-api-token",
224        },
225        "profiles": {
226            "work": {
227                "host": "work.atlassian.net",
228                "email": "me@work.com",
229                "token": "work-token",
230            }
231        }
232    });
233
234    if out.json {
235        out.print_data(
236            &serde_json::to_string_pretty(&serde_json::json!({
237                "configPath": path,
238                "pathResolution": path_resolution,
239                "tokenInstructions": "https://id.atlassian.com/manage-profile/security/api-tokens",
240                "recommendedPermissions": permission_advice,
241                "example": example,
242            }))
243            .expect("failed to serialize JSON"),
244        );
245        return;
246    }
247
248    out.print_data(&format!(
249        "Create or edit: {}\nPath resolution: {}\n\nExample config:\n\n[default]\nhost  = \"mycompany.atlassian.net\"\nemail = \"me@example.com\"\ntoken = \"your-api-token\"\n\n# Optional named profiles:\n# [profiles.work]\n# host  = \"work.atlassian.net\"\n# email = \"me@work.com\"\n# token = \"work-token\"\n\nGet your API token at: https://id.atlassian.com/manage-profile/security/api-tokens\n\nPermissions: {}",
250        path.display(),
251        path_resolution,
252        permission_advice,
253    ));
254}
255
256/// Mask a token for display, showing only the last 4 characters.
257///
258/// Atlassian tokens begin with a predictable prefix, so showing the
259/// start provides no meaningful identification — the end is more useful.
260fn mask_token(token: &str) -> String {
261    let n = token.chars().count();
262    if n > 4 {
263        let suffix: String = token.chars().skip(n - 4).collect();
264        format!("***{suffix}")
265    } else {
266        "***".into()
267    }
268}
269
270fn env_var(name: &str) -> Option<String> {
271    std::env::var(name)
272        .ok()
273        .and_then(|value| normalize_value(Some(value)))
274}
275
276fn normalize_value(value: Option<String>) -> Option<String> {
277    value.and_then(|value| {
278        let trimmed = value.trim();
279        if trimmed.is_empty() {
280            None
281        } else {
282            Some(trimmed.to_string())
283        }
284    })
285}
286
287fn normalize_str(value: Option<&str>) -> Option<&str> {
288    value.and_then(|value| {
289        let trimmed = value.trim();
290        if trimmed.is_empty() {
291            None
292        } else {
293            Some(trimmed)
294        }
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
302    use tempfile::TempDir;
303
304    #[test]
305    fn mask_token_long() {
306        let masked = mask_token("ATATxxx1234abcd");
307        assert!(masked.starts_with("***"));
308        assert!(masked.ends_with("abcd"));
309    }
310
311    #[test]
312    fn mask_token_short() {
313        assert_eq!(mask_token("abc"), "***");
314    }
315
316    #[test]
317    fn mask_token_unicode_safe() {
318        // Ensure char-based indexing doesn't panic on multi-byte chars
319        let token = "token-日本語-end";
320        let result = mask_token(token);
321        assert!(result.starts_with("***"));
322    }
323
324    #[test]
325    #[cfg(not(target_os = "windows"))]
326    fn config_path_prefers_xdg_config_home() {
327        let _env = ProcessEnvLock::acquire().unwrap();
328        let dir = TempDir::new().unwrap();
329        let _config_dir = set_config_dir_env(dir.path());
330
331        assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
332    }
333
334    #[test]
335    fn load_ignores_blank_env_vars_and_falls_back_to_file() {
336        let _env = ProcessEnvLock::acquire().unwrap();
337        let dir = TempDir::new().unwrap();
338        write_config(
339            dir.path(),
340            r#"
341[default]
342host = "work.atlassian.net"
343email = "me@example.com"
344token = "secret-token"
345"#,
346        )
347        .unwrap();
348
349        let _config_dir = set_config_dir_env(dir.path());
350        let _host = EnvVarGuard::set("JIRA_HOST", "   ");
351        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
352        let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
353        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
354
355        let cfg = Config::load(None, None, None).unwrap();
356        assert_eq!(cfg.host, "work.atlassian.net");
357        assert_eq!(cfg.email, "me@example.com");
358        assert_eq!(cfg.token, "secret-token");
359    }
360
361    #[test]
362    fn load_accepts_documented_default_section() {
363        let _env = ProcessEnvLock::acquire().unwrap();
364        let dir = TempDir::new().unwrap();
365        write_config(
366            dir.path(),
367            r#"
368[default]
369host = "example.atlassian.net"
370email = "me@example.com"
371token = "secret-token"
372"#,
373        )
374        .unwrap();
375
376        let _config_dir = set_config_dir_env(dir.path());
377        let _host = EnvVarGuard::unset("JIRA_HOST");
378        let _email = EnvVarGuard::unset("JIRA_EMAIL");
379        let _token = EnvVarGuard::unset("JIRA_TOKEN");
380        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
381
382        let cfg = Config::load(None, None, None).unwrap();
383        assert_eq!(cfg.host, "example.atlassian.net");
384        assert_eq!(cfg.email, "me@example.com");
385        assert_eq!(cfg.token, "secret-token");
386    }
387
388    #[test]
389    fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
390        let _env = ProcessEnvLock::acquire().unwrap();
391        let dir = TempDir::new().unwrap();
392        let _config_dir = set_config_dir_env(dir.path());
393        let _host = EnvVarGuard::set("JIRA_HOST", "");
394        let _email = EnvVarGuard::set("JIRA_EMAIL", "");
395        let _token = EnvVarGuard::set("JIRA_TOKEN", "");
396        let _profile = EnvVarGuard::unset("JIRA_PROFILE");
397
398        let err = Config::load(None, None, None).unwrap_err();
399        assert!(matches!(err, ApiError::InvalidInput(_)));
400        assert!(err.to_string().contains("No Jira host configured"));
401    }
402
403    #[test]
404    fn permission_guidance_matches_platform() {
405        let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
406
407        #[cfg(target_os = "windows")]
408        assert!(guidance.contains("AppData"));
409
410        #[cfg(not(target_os = "windows"))]
411        assert!(guidance.starts_with("chmod 600 "));
412    }
413}