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
534 #[test]
537 fn load_env_host_overrides_file() {
538 let _env = ProcessEnvLock::acquire().unwrap();
539 let dir = TempDir::new().unwrap();
540 write_config(
541 dir.path(),
542 r#"
543[default]
544host = "file.atlassian.net"
545email = "me@example.com"
546token = "tok"
547"#,
548 )
549 .unwrap();
550
551 let _config_dir = set_config_dir_env(dir.path());
552 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
553 let _email = EnvVarGuard::unset("JIRA_EMAIL");
554 let _token = EnvVarGuard::unset("JIRA_TOKEN");
555 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
556
557 let cfg = Config::load(None, None, None).unwrap();
558 assert_eq!(cfg.host, "env.atlassian.net");
559 }
560
561 #[test]
562 fn load_cli_host_arg_overrides_env_and_file() {
563 let _env = ProcessEnvLock::acquire().unwrap();
564 let dir = TempDir::new().unwrap();
565 write_config(
566 dir.path(),
567 r#"
568[default]
569host = "file.atlassian.net"
570email = "me@example.com"
571token = "tok"
572"#,
573 )
574 .unwrap();
575
576 let _config_dir = set_config_dir_env(dir.path());
577 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
578 let _email = EnvVarGuard::unset("JIRA_EMAIL");
579 let _token = EnvVarGuard::unset("JIRA_TOKEN");
580 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
581
582 let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
583 assert_eq!(cfg.host, "cli.atlassian.net");
584 }
585
586 #[test]
589 fn load_missing_token_returns_error() {
590 let _env = ProcessEnvLock::acquire().unwrap();
591 let dir = TempDir::new().unwrap();
592 let _config_dir = set_config_dir_env(dir.path());
593 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
594 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
595 let _token = EnvVarGuard::unset("JIRA_TOKEN");
596 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
597
598 let err = Config::load(None, None, None).unwrap_err();
599 assert!(matches!(err, ApiError::InvalidInput(_)));
600 assert!(err.to_string().contains("No API token"));
601 }
602
603 #[test]
604 fn load_missing_email_for_basic_auth_returns_error() {
605 let _env = ProcessEnvLock::acquire().unwrap();
606 let dir = TempDir::new().unwrap();
607 let _config_dir = set_config_dir_env(dir.path());
608 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
609 let _email = EnvVarGuard::unset("JIRA_EMAIL");
610 let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
611 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
612 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
613
614 let err = Config::load(None, None, None).unwrap_err();
615 assert!(matches!(err, ApiError::InvalidInput(_)));
616 assert!(err.to_string().contains("No email configured"));
617 }
618
619 #[test]
620 fn load_invalid_toml_returns_error() {
621 let _env = ProcessEnvLock::acquire().unwrap();
622 let dir = TempDir::new().unwrap();
623 write_config(dir.path(), "host = [invalid toml").unwrap();
624
625 let _config_dir = set_config_dir_env(dir.path());
626 let _host = EnvVarGuard::unset("JIRA_HOST");
627 let _token = EnvVarGuard::unset("JIRA_TOKEN");
628 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
629
630 let err = Config::load(None, None, None).unwrap_err();
631 assert!(matches!(err, ApiError::Other(_)));
632 assert!(err.to_string().contains("parse"));
633 }
634
635 #[test]
638 fn load_pat_auth_does_not_require_email() {
639 let _env = ProcessEnvLock::acquire().unwrap();
640 let dir = TempDir::new().unwrap();
641 write_config(
642 dir.path(),
643 r#"
644[default]
645host = "jira.corp.com"
646token = "my-pat-token"
647auth_type = "pat"
648api_version = 2
649"#,
650 )
651 .unwrap();
652
653 let _config_dir = set_config_dir_env(dir.path());
654 let _host = EnvVarGuard::unset("JIRA_HOST");
655 let _email = EnvVarGuard::unset("JIRA_EMAIL");
656 let _token = EnvVarGuard::unset("JIRA_TOKEN");
657 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
658 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
659
660 let cfg = Config::load(None, None, None).unwrap();
661 assert_eq!(cfg.auth_type, AuthType::Pat);
662 assert_eq!(cfg.api_version, 2);
663 assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
664 }
665
666 #[test]
667 fn load_jira_auth_type_env_pat_overrides_basic() {
668 let _env = ProcessEnvLock::acquire().unwrap();
669 let dir = TempDir::new().unwrap();
670 write_config(
671 dir.path(),
672 r#"
673[default]
674host = "jira.corp.com"
675email = "me@example.com"
676token = "tok"
677auth_type = "basic"
678"#,
679 )
680 .unwrap();
681
682 let _config_dir = set_config_dir_env(dir.path());
683 let _host = EnvVarGuard::unset("JIRA_HOST");
684 let _email = EnvVarGuard::unset("JIRA_EMAIL");
685 let _token = EnvVarGuard::unset("JIRA_TOKEN");
686 let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
687 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
688
689 let cfg = Config::load(None, None, None).unwrap();
690 assert_eq!(cfg.auth_type, AuthType::Pat);
691 }
692
693 #[test]
694 fn load_jira_api_version_env_overrides_default() {
695 let _env = ProcessEnvLock::acquire().unwrap();
696 let dir = TempDir::new().unwrap();
697 let _config_dir = set_config_dir_env(dir.path());
698 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
699 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
700 let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
701 let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
702 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
703 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
704
705 let cfg = Config::load(None, None, None).unwrap();
706 assert_eq!(cfg.api_version, 2);
707 }
708
709 #[test]
712 fn load_profile_arg_selects_named_section() {
713 let _env = ProcessEnvLock::acquire().unwrap();
714 let dir = TempDir::new().unwrap();
715 write_config(
716 dir.path(),
717 r#"
718[default]
719host = "default.atlassian.net"
720email = "default@example.com"
721token = "default-tok"
722
723[profiles.work]
724host = "work.atlassian.net"
725email = "me@work.com"
726token = "work-tok"
727"#,
728 )
729 .unwrap();
730
731 let _config_dir = set_config_dir_env(dir.path());
732 let _host = EnvVarGuard::unset("JIRA_HOST");
733 let _email = EnvVarGuard::unset("JIRA_EMAIL");
734 let _token = EnvVarGuard::unset("JIRA_TOKEN");
735 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
736
737 let cfg = Config::load(None, None, Some("work".into())).unwrap();
738 assert_eq!(cfg.host, "work.atlassian.net");
739 assert_eq!(cfg.email, "me@work.com");
740 assert_eq!(cfg.token, "work-tok");
741 }
742
743 #[test]
744 fn load_jira_profile_env_selects_named_section() {
745 let _env = ProcessEnvLock::acquire().unwrap();
746 let dir = TempDir::new().unwrap();
747 write_config(
748 dir.path(),
749 r#"
750[default]
751host = "default.atlassian.net"
752email = "default@example.com"
753token = "default-tok"
754
755[profiles.staging]
756host = "staging.atlassian.net"
757email = "me@staging.com"
758token = "staging-tok"
759"#,
760 )
761 .unwrap();
762
763 let _config_dir = set_config_dir_env(dir.path());
764 let _host = EnvVarGuard::unset("JIRA_HOST");
765 let _email = EnvVarGuard::unset("JIRA_EMAIL");
766 let _token = EnvVarGuard::unset("JIRA_TOKEN");
767 let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
768
769 let cfg = Config::load(None, None, None).unwrap();
770 assert_eq!(cfg.host, "staging.atlassian.net");
771 }
772
773 #[test]
774 fn load_unknown_profile_returns_descriptive_error() {
775 let _env = ProcessEnvLock::acquire().unwrap();
776 let dir = TempDir::new().unwrap();
777 write_config(
778 dir.path(),
779 r#"
780[profiles.alpha]
781host = "alpha.atlassian.net"
782email = "me@alpha.com"
783token = "alpha-tok"
784"#,
785 )
786 .unwrap();
787
788 let _config_dir = set_config_dir_env(dir.path());
789 let _host = EnvVarGuard::unset("JIRA_HOST");
790 let _token = EnvVarGuard::unset("JIRA_TOKEN");
791 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
792
793 let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
794 assert!(matches!(err, ApiError::Other(_)));
795 let msg = err.to_string();
796 assert!(
797 msg.contains("nonexistent"),
798 "error should name the bad profile"
799 );
800 assert!(
801 msg.contains("alpha"),
802 "error should list available profiles"
803 );
804 }
805
806 #[test]
809 fn show_json_output_includes_host_and_masked_token() {
810 let _env = ProcessEnvLock::acquire().unwrap();
811 let dir = TempDir::new().unwrap();
812 write_config(
813 dir.path(),
814 r#"
815[default]
816host = "show-test.atlassian.net"
817email = "me@example.com"
818token = "supersecrettoken"
819"#,
820 )
821 .unwrap();
822
823 let _config_dir = set_config_dir_env(dir.path());
824 let _host = EnvVarGuard::unset("JIRA_HOST");
825 let _email = EnvVarGuard::unset("JIRA_EMAIL");
826 let _token = EnvVarGuard::unset("JIRA_TOKEN");
827 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
828
829 let out = crate::output::OutputConfig::new(true, true);
830 show(&out, None, None, None).unwrap();
832 }
833
834 #[test]
835 fn show_text_output_renders_without_error() {
836 let _env = ProcessEnvLock::acquire().unwrap();
837 let dir = TempDir::new().unwrap();
838 write_config(
839 dir.path(),
840 r#"
841[default]
842host = "show-test.atlassian.net"
843email = "me@example.com"
844token = "supersecrettoken"
845"#,
846 )
847 .unwrap();
848
849 let _config_dir = set_config_dir_env(dir.path());
850 let _host = EnvVarGuard::unset("JIRA_HOST");
851 let _email = EnvVarGuard::unset("JIRA_EMAIL");
852 let _token = EnvVarGuard::unset("JIRA_TOKEN");
853 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
854
855 let out = crate::output::OutputConfig::new(false, true);
856 show(&out, None, None, None).unwrap();
857 }
858
859 #[test]
862 fn init_json_output_includes_example_and_paths() {
863 let out = crate::output::OutputConfig::new(true, true);
864 init(&out, Some("jira.corp.com"));
866 }
867
868 #[test]
869 fn init_text_output_renders_without_error() {
870 let out = crate::output::OutputConfig::new(false, true);
871 init(&out, None);
872 }
873
874 #[test]
877 fn dc_pat_url_without_host_returns_placeholder() {
878 let url = dc_pat_url(None);
879 assert!(url.starts_with("http://<your-host>"));
880 assert!(url.contains(PAT_PATH));
881 }
882
883 #[test]
884 fn dc_pat_url_bare_host_adds_https_scheme() {
885 let url = dc_pat_url(Some("jira.corp.com"));
886 assert!(url.starts_with("https://jira.corp.com"));
887 assert!(url.contains(PAT_PATH));
888 }
889
890 #[test]
891 fn dc_pat_url_host_with_https_scheme_is_preserved() {
892 let url = dc_pat_url(Some("https://jira.corp.com/"));
893 assert!(url.starts_with("https://jira.corp.com"));
894 assert!(!url.contains("https://https://"));
895 assert!(url.contains(PAT_PATH));
896 }
897
898 #[test]
899 fn dc_pat_url_host_with_http_scheme_is_preserved() {
900 let url = dc_pat_url(Some("http://localhost:8080"));
901 assert!(url.starts_with("http://localhost:8080"));
902 assert!(url.contains(PAT_PATH));
903 }
904}