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}
18
19#[derive(Debug, Deserialize, Default)]
20struct RawConfig {
21 #[serde(default)]
22 default: ProfileConfig,
23 #[serde(default)]
24 profiles: BTreeMap<String, ProfileConfig>,
25 host: Option<String>,
26 email: Option<String>,
27 token: Option<String>,
28 auth_type: Option<String>,
29 api_version: Option<u8>,
30}
31
32impl RawConfig {
33 fn default_profile(&self) -> ProfileConfig {
34 ProfileConfig {
35 host: self.default.host.clone().or_else(|| self.host.clone()),
36 email: self.default.email.clone().or_else(|| self.email.clone()),
37 token: self.default.token.clone().or_else(|| self.token.clone()),
38 auth_type: self
39 .default
40 .auth_type
41 .clone()
42 .or_else(|| self.auth_type.clone()),
43 api_version: self.default.api_version.or(self.api_version),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct Config {
51 pub host: String,
52 pub email: String,
53 pub token: String,
54 pub auth_type: AuthType,
55 pub api_version: u8,
56}
57
58impl Config {
59 pub fn load(
65 host_arg: Option<String>,
66 email_arg: Option<String>,
67 profile_arg: Option<String>,
68 ) -> Result<Self, ApiError> {
69 let file_profile = load_file_profile(profile_arg.as_deref())?;
70
71 let host = normalize_value(host_arg)
72 .or_else(|| env_var("JIRA_HOST"))
73 .or_else(|| normalize_value(file_profile.host))
74 .ok_or_else(|| {
75 ApiError::InvalidInput(
76 "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
77 )
78 })?;
79
80 let token = env_var("JIRA_TOKEN")
81 .or_else(|| normalize_value(file_profile.token.clone()))
82 .ok_or_else(|| {
83 ApiError::InvalidInput(
84 "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
85 )
86 })?;
87
88 let auth_type = env_var("JIRA_AUTH_TYPE")
89 .as_deref()
90 .map(|v| {
91 if v.eq_ignore_ascii_case("pat") {
92 AuthType::Pat
93 } else {
94 AuthType::Basic
95 }
96 })
97 .or_else(|| {
98 file_profile.auth_type.as_deref().map(|v| {
99 if v.eq_ignore_ascii_case("pat") {
100 AuthType::Pat
101 } else {
102 AuthType::Basic
103 }
104 })
105 })
106 .unwrap_or_default();
107
108 let api_version = env_var("JIRA_API_VERSION")
109 .and_then(|v| v.parse::<u8>().ok())
110 .or(file_profile.api_version)
111 .unwrap_or(3);
112
113 let email = normalize_value(email_arg)
115 .or_else(|| env_var("JIRA_EMAIL"))
116 .or_else(|| normalize_value(file_profile.email));
117
118 let email = match auth_type {
119 AuthType::Basic => email.ok_or_else(|| {
120 ApiError::InvalidInput(
121 "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
122 )
123 })?,
124 AuthType::Pat => email.unwrap_or_default(),
125 };
126
127 Ok(Self {
128 host,
129 email,
130 token,
131 auth_type,
132 api_version,
133 })
134 }
135}
136
137fn config_path() -> PathBuf {
138 config_dir()
139 .unwrap_or_else(|| PathBuf::from(".config"))
140 .join("jira")
141 .join("config.toml")
142}
143
144pub fn schema_config_path() -> String {
145 config_path().display().to_string()
146}
147
148pub fn schema_config_path_description() -> &'static str {
149 #[cfg(target_os = "windows")]
150 {
151 "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
152 }
153
154 #[cfg(not(target_os = "windows"))]
155 {
156 "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
157 }
158}
159
160pub fn recommended_permissions(path: &std::path::Path) -> String {
161 #[cfg(target_os = "windows")]
162 {
163 format!(
164 "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
165 path.display()
166 )
167 }
168
169 #[cfg(not(target_os = "windows"))]
170 {
171 format!("chmod 600 {}", path.display())
172 }
173}
174
175pub fn schema_recommended_permissions_example() -> &'static str {
176 #[cfg(target_os = "windows")]
177 {
178 "Keep the file in your per-user %APPDATA% directory and out of shared folders."
179 }
180
181 #[cfg(not(target_os = "windows"))]
182 {
183 "chmod 600 /path/to/config.toml"
184 }
185}
186
187fn config_dir() -> Option<PathBuf> {
188 #[cfg(target_os = "windows")]
189 {
190 dirs::config_dir()
191 }
192
193 #[cfg(not(target_os = "windows"))]
194 {
195 std::env::var_os("XDG_CONFIG_HOME")
196 .filter(|value| !value.is_empty())
197 .map(PathBuf::from)
198 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
199 }
200}
201
202fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
203 let path = config_path();
204 let content = match std::fs::read_to_string(&path) {
205 Ok(c) => c,
206 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
207 Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
208 };
209
210 let raw: RawConfig = toml::from_str(&content)
211 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
212
213 let profile_name = normalize_str(profile)
214 .map(str::to_owned)
215 .or_else(|| env_var("JIRA_PROFILE"));
216
217 match profile_name {
218 Some(name) => {
219 let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
221 raw.profiles.get(&name).cloned().ok_or_else(|| {
222 ApiError::Other(format!(
223 "Profile '{name}' not found in config. Available: {}",
224 available.join(", ")
225 ))
226 })
227 }
228 None => Ok(raw.default_profile()),
229 }
230}
231
232pub fn show(
234 out: &OutputConfig,
235 host_arg: Option<String>,
236 email_arg: Option<String>,
237 profile_arg: Option<String>,
238) -> Result<(), ApiError> {
239 let path = config_path();
240 let cfg = Config::load(host_arg, email_arg, profile_arg)?;
241 let masked = mask_token(&cfg.token);
242
243 if out.json {
244 out.print_data(
245 &serde_json::to_string_pretty(&serde_json::json!({
246 "configPath": path,
247 "host": cfg.host,
248 "email": cfg.email,
249 "tokenMasked": masked,
250 }))
251 .expect("failed to serialize JSON"),
252 );
253 } else {
254 out.print_message(&format!("Config file: {}", path.display()));
255 out.print_data(&format!(
256 "host: {}\nemail: {}\ntoken: {masked}",
257 cfg.host, cfg.email
258 ));
259 }
260 Ok(())
261}
262
263pub fn init(out: &OutputConfig, host: Option<&str>) {
269 let path = config_path();
270 let path_resolution = schema_config_path_description();
271 let permission_advice = recommended_permissions(&path);
272 let example = serde_json::json!({
273 "default": {
274 "host": "mycompany.atlassian.net",
275 "email": "me@example.com",
276 "token": "your-api-token",
277 "auth_type": "basic",
278 "api_version": 3,
279 },
280 "profiles": {
281 "work": {
282 "host": "work.atlassian.net",
283 "email": "me@work.com",
284 "token": "work-token",
285 },
286 "datacenter": {
287 "host": "jira.mycompany.com",
288 "token": "your-personal-access-token",
289 "auth_type": "pat",
290 "api_version": 2,
291 }
292 }
293 });
294
295 const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
296
297 let pat_url = dc_pat_url(host);
298 let config_status = if path.exists() {
299 "exists — run `jira config show` to see current values"
300 } else {
301 "not found — create it"
302 };
303
304 if out.json {
305 out.print_data(
306 &serde_json::to_string_pretty(&serde_json::json!({
307 "configPath": path,
308 "pathResolution": path_resolution,
309 "configExists": path.exists(),
310 "tokenInstructions": CLOUD_TOKEN_URL,
311 "dcPatInstructions": pat_url,
312 "recommendedPermissions": permission_advice,
313 "example": example,
314 }))
315 .expect("failed to serialize JSON"),
316 );
317 return;
318 }
319
320 let cloud_link = crate::output::hyperlink(CLOUD_TOKEN_URL);
321 let pat_link = crate::output::hyperlink(&pat_url);
322
323 out.print_data(&format!(
324 "\
325Config file: {path_display} ({config_status})
326
327── Jira Cloud ────────────────────────────────────────────────────────────────
328
329[default]
330host = \"mycompany.atlassian.net\"
331email = \"me@example.com\"
332token = \"your-api-token\"
333
334 {cloud_link}
335
336── Jira Data Center / Server ─────────────────────────────────────────────────
337
338[profiles.dc]
339host = \"jira.mycompany.com\"
340token = \"your-personal-access-token\"
341auth_type = \"pat\"
342api_version = 2
343
344 {pat_link}
345
346Use --profile dc to switch: jira --profile dc <command>
347 or: JIRA_PROFILE=dc jira <command>
348
349── Security ──────────────────────────────────────────────────────────────────
350
351{permission_advice}",
352 path_display = path.display(),
353 ));
354}
355
356const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
357
358fn dc_pat_url(host: Option<&str>) -> String {
363 match host {
364 Some(h) => {
365 let base = if h.starts_with("http://") || h.starts_with("https://") {
366 h.trim_end_matches('/').to_string()
367 } else {
368 format!("https://{}", h.trim_end_matches('/'))
369 };
370 format!("{base}{PAT_PATH}")
371 }
372 None => format!("http://<your-host>{PAT_PATH}"),
373 }
374}
375
376fn mask_token(token: &str) -> String {
381 let n = token.chars().count();
382 if n > 4 {
383 let suffix: String = token.chars().skip(n - 4).collect();
384 format!("***{suffix}")
385 } else {
386 "***".into()
387 }
388}
389
390fn env_var(name: &str) -> Option<String> {
391 std::env::var(name)
392 .ok()
393 .and_then(|value| normalize_value(Some(value)))
394}
395
396fn normalize_value(value: Option<String>) -> Option<String> {
397 value.and_then(|value| {
398 let trimmed = value.trim();
399 if trimmed.is_empty() {
400 None
401 } else {
402 Some(trimmed.to_string())
403 }
404 })
405}
406
407fn normalize_str(value: Option<&str>) -> Option<&str> {
408 value.and_then(|value| {
409 let trimmed = value.trim();
410 if trimmed.is_empty() {
411 None
412 } else {
413 Some(trimmed)
414 }
415 })
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
422 use tempfile::TempDir;
423
424 #[test]
425 fn mask_token_long() {
426 let masked = mask_token("ATATxxx1234abcd");
427 assert!(masked.starts_with("***"));
428 assert!(masked.ends_with("abcd"));
429 }
430
431 #[test]
432 fn mask_token_short() {
433 assert_eq!(mask_token("abc"), "***");
434 }
435
436 #[test]
437 fn mask_token_unicode_safe() {
438 let token = "token-日本語-end";
440 let result = mask_token(token);
441 assert!(result.starts_with("***"));
442 }
443
444 #[test]
445 #[cfg(not(target_os = "windows"))]
446 fn config_path_prefers_xdg_config_home() {
447 let _env = ProcessEnvLock::acquire().unwrap();
448 let dir = TempDir::new().unwrap();
449 let _config_dir = set_config_dir_env(dir.path());
450
451 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
452 }
453
454 #[test]
455 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
456 let _env = ProcessEnvLock::acquire().unwrap();
457 let dir = TempDir::new().unwrap();
458 write_config(
459 dir.path(),
460 r#"
461[default]
462host = "work.atlassian.net"
463email = "me@example.com"
464token = "secret-token"
465"#,
466 )
467 .unwrap();
468
469 let _config_dir = set_config_dir_env(dir.path());
470 let _host = EnvVarGuard::set("JIRA_HOST", " ");
471 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
472 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
473 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
474
475 let cfg = Config::load(None, None, None).unwrap();
476 assert_eq!(cfg.host, "work.atlassian.net");
477 assert_eq!(cfg.email, "me@example.com");
478 assert_eq!(cfg.token, "secret-token");
479 }
480
481 #[test]
482 fn load_accepts_documented_default_section() {
483 let _env = ProcessEnvLock::acquire().unwrap();
484 let dir = TempDir::new().unwrap();
485 write_config(
486 dir.path(),
487 r#"
488[default]
489host = "example.atlassian.net"
490email = "me@example.com"
491token = "secret-token"
492"#,
493 )
494 .unwrap();
495
496 let _config_dir = set_config_dir_env(dir.path());
497 let _host = EnvVarGuard::unset("JIRA_HOST");
498 let _email = EnvVarGuard::unset("JIRA_EMAIL");
499 let _token = EnvVarGuard::unset("JIRA_TOKEN");
500 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
501
502 let cfg = Config::load(None, None, None).unwrap();
503 assert_eq!(cfg.host, "example.atlassian.net");
504 assert_eq!(cfg.email, "me@example.com");
505 assert_eq!(cfg.token, "secret-token");
506 }
507
508 #[test]
509 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
510 let _env = ProcessEnvLock::acquire().unwrap();
511 let dir = TempDir::new().unwrap();
512 let _config_dir = set_config_dir_env(dir.path());
513 let _host = EnvVarGuard::set("JIRA_HOST", "");
514 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
515 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
516 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
517
518 let err = Config::load(None, None, None).unwrap_err();
519 assert!(matches!(err, ApiError::InvalidInput(_)));
520 assert!(err.to_string().contains("No Jira host configured"));
521 }
522
523 #[test]
524 fn permission_guidance_matches_platform() {
525 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
526
527 #[cfg(target_os = "windows")]
528 assert!(guidance.contains("AppData"));
529
530 #[cfg(not(target_os = "windows"))]
531 assert!(guidance.starts_with("chmod 600 "));
532 }
533}