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 async fn init(out: &OutputConfig, host: Option<&str>) {
270 if out.json {
271 init_json(out, host);
272 return;
273 }
274
275 use std::io::IsTerminal;
276 if !std::io::stdin().is_terminal() {
277 out.print_message(
278 "Run `jira init` in an interactive terminal to configure credentials, \
279 or use `jira init --json` for setup instructions.",
280 );
281 return;
282 }
283
284 if let Err(e) = init_interactive(host).await {
285 eprintln!("{} {e}", sym_fail());
286 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
287 }
288}
289
290fn init_json(out: &OutputConfig, host: Option<&str>) {
291 let path = config_path();
292 let path_resolution = schema_config_path_description();
293 let permission_advice = recommended_permissions(&path);
294 let example = serde_json::json!({
295 "default": {
296 "host": "mycompany.atlassian.net",
297 "email": "me@example.com",
298 "token": "your-api-token",
299 "auth_type": "basic",
300 "api_version": 3,
301 },
302 "profiles": {
303 "work": {
304 "host": "work.atlassian.net",
305 "email": "me@work.com",
306 "token": "work-token",
307 },
308 "datacenter": {
309 "host": "jira.mycompany.com",
310 "token": "your-personal-access-token",
311 "auth_type": "pat",
312 "api_version": 2,
313 }
314 }
315 });
316
317 const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
318 let pat_url = dc_pat_url(host);
319
320 out.print_data(
321 &serde_json::to_string_pretty(&serde_json::json!({
322 "configPath": path,
323 "pathResolution": path_resolution,
324 "configExists": path.exists(),
325 "tokenInstructions": CLOUD_TOKEN_URL,
326 "dcPatInstructions": pat_url,
327 "recommendedPermissions": permission_advice,
328 "example": example,
329 }))
330 .expect("failed to serialize JSON"),
331 );
332}
333
334async fn init_interactive(prefill_host: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
335 let sep = sym_dim("──────────────");
336 eprintln!("Jira CLI Setup");
337 eprintln!("{sep}");
338
339 let path = config_path();
340
341 let (target_name, existing): (Option<String>, Option<ProfileConfig>) = if path.exists() {
347 let profiles = list_profile_names(&path)?;
348
349 eprintln!();
352 eprintln!(
353 " {} {}",
354 sym_dim("Config:"),
355 sym_dim(&path.display().to_string())
356 );
357 eprintln!();
358 eprintln!(" {}:", sym_dim("Profiles"));
359 for name in &profiles {
360 let host = read_raw_profile(&path, name)
361 .ok()
362 .and_then(|p| p.host)
363 .unwrap_or_default();
364 eprintln!(" {} {} {}", sym_dim("•"), name, sym_dim(&host));
365 }
366 eprintln!();
367
368 let action = prompt("Action", "[update/add]", Some("update"))?;
369 eprintln!();
370
371 if !action.trim().eq_ignore_ascii_case("add") {
372 let default = profiles.first().map(String::as_str).unwrap_or("default");
373 let raw = if profiles.len() > 1 {
374 prompt("Profile", "", Some(default))?
375 } else {
376 default.to_owned()
377 };
378 let name = if raw.trim().is_empty() {
379 default.to_owned()
380 } else {
381 raw.trim().to_owned()
382 };
383 let cfg = read_raw_profile(&path, &name)?;
384 if profiles.len() > 1 {
385 eprintln!();
386 }
387 (Some(name), Some(cfg))
388 } else {
389 (None, None)
390 }
391 } else {
392 eprintln!();
394 (Some("default".to_owned()), None)
395 };
396
397 let is_cloud = if let Some(ref p) = existing {
399 p.auth_type.as_deref() != Some("pat")
400 } else {
401 let t = prompt("Type", sym_dim("[cloud/dc]").as_str(), Some("cloud"))?;
402 eprintln!();
403 !t.trim().eq_ignore_ascii_case("dc")
404 };
405
406 let host = if is_cloud {
408 let default_sub = existing
409 .as_ref()
410 .and_then(|p| p.host.clone())
411 .as_deref()
412 .or(prefill_host)
413 .map(|h| h.trim_end_matches(".atlassian.net").to_owned());
414 let raw = prompt_required("Subdomain", "", default_sub.as_deref())?;
415 let sub = raw.trim().trim_end_matches(".atlassian.net");
416 format!("{sub}.atlassian.net")
417 } else {
418 let default = existing
419 .as_ref()
420 .and_then(|p| p.host.clone())
421 .or_else(|| prefill_host.map(str::to_owned));
422 prompt_required("Host", "", default.as_deref())?
423 };
424
425 let keep_hint = sym_dim(" (Enter to keep)");
427 let (email, token, auth_type, api_version): (Option<String>, String, &str, u8) = if is_cloud {
428 const CLOUD_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
429 let default_email = existing.as_ref().and_then(|p| p.email.clone());
430 let email = prompt_required("Email", "", default_email.as_deref())?;
431 eprintln!(" {}", sym_dim(&format!("→ {CLOUD_URL}")));
432 let token_prompt = format!(
433 "{} Token{}: ",
434 sym_q(),
435 if existing.is_some() {
436 keep_hint.as_str()
437 } else {
438 ""
439 }
440 );
441 let raw = rpassword::prompt_password(token_prompt)?;
442 let token = if raw.trim().is_empty() {
443 existing
444 .as_ref()
445 .and_then(|p| p.token.clone())
446 .ok_or("No existing token — please enter a token.")?
447 } else {
448 raw
449 };
450 (Some(email), token, "basic", 3)
451 } else {
452 let pat_url = dc_pat_url(Some(&host));
453 eprintln!(" {}", sym_dim(&format!("→ {pat_url}")));
454 let token_prompt = format!(
455 "{} Token{}: ",
456 sym_q(),
457 if existing.is_some() {
458 keep_hint.as_str()
459 } else {
460 ""
461 }
462 );
463 let raw = rpassword::prompt_password(token_prompt)?;
464 let token = if raw.trim().is_empty() {
465 existing
466 .as_ref()
467 .and_then(|p| p.token.clone())
468 .ok_or("No existing token — please enter a token.")?
469 } else {
470 raw
471 };
472 let default_ver = existing
473 .as_ref()
474 .and_then(|p| p.api_version.map(|v| v.to_string()))
475 .unwrap_or_else(|| "2".to_owned());
476 let ver_str = prompt("API version", "", Some(&default_ver))?;
477 let api_version: u8 = ver_str.trim().parse().unwrap_or(2);
478 (None, token, "pat", api_version)
479 };
480
481 use std::io::Write;
483 eprintln!();
484 eprint!(" Verifying credentials...");
485 std::io::stderr().flush().ok();
486
487 let auth_type_enum = if auth_type == "pat" {
488 AuthType::Pat
489 } else {
490 AuthType::Basic
491 };
492
493 let verified = match crate::api::client::JiraClient::new(
494 &host,
495 email.as_deref().unwrap_or(""),
496 &token,
497 auth_type_enum,
498 api_version,
499 ) {
500 Err(e) => {
501 eprintln!(" {} {e}", sym_fail());
502 return Err(e.into());
503 }
504 Ok(client) => match client.get_myself().await {
505 Ok(myself) => {
506 eprintln!(" {} Authenticated as {}", sym_ok(), myself.display_name);
507 true
508 }
509 Err(e) => {
510 eprintln!(" {} {e}", sym_fail());
511 eprintln!();
512 let save = prompt("Save config anyway?", sym_dim("[y/N]").as_str(), Some("n"))?;
513 save.trim().eq_ignore_ascii_case("y")
514 }
515 },
516 };
517
518 if !verified {
519 eprintln!();
520 eprintln!("{sep}");
521 return Ok(());
522 }
523
524 let profile_name = match target_name {
526 Some(name) => name,
527 None => {
528 eprintln!();
529 let raw = prompt_required("Profile name", "", Some("default"))?;
530 if raw.trim().is_empty() {
531 "default".to_owned()
532 } else {
533 raw.trim().to_owned()
534 }
535 }
536 };
537
538 write_profile_to_config(
540 &path,
541 &profile_name,
542 &host,
543 email.as_deref(),
544 &token,
545 auth_type,
546 api_version,
547 )?;
548
549 #[cfg(unix)]
550 {
551 use std::os::unix::fs::PermissionsExt;
552 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
553 }
554
555 eprintln!();
556 eprintln!(" {} Config written to {}", sym_ok(), path.display());
557 eprintln!("{sep}");
558 if profile_name == "default" {
559 eprintln!(" Run: jira projects list");
560 } else {
561 eprintln!(" Run: jira --profile {profile_name} projects list");
562 }
563 eprintln!();
564
565 Ok(())
566}
567
568fn list_profile_names(path: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
570 let content = std::fs::read_to_string(path)?;
571 let doc: toml::Value = toml::from_str(&content)?;
572 let table = doc.as_table().ok_or("config is not a TOML table")?;
573
574 let mut names = Vec::new();
575 if table.contains_key("default") {
576 names.push("default".to_owned());
577 }
578 if let Some(profiles) = table.get("profiles").and_then(toml::Value::as_table) {
579 for name in profiles.keys() {
580 names.push(name.clone());
581 }
582 }
583 Ok(names)
584}
585
586fn read_raw_profile(
588 path: &std::path::Path,
589 name: &str,
590) -> Result<ProfileConfig, Box<dyn std::error::Error>> {
591 let content = std::fs::read_to_string(path)?;
592 let raw: RawConfig = toml::from_str(&content)?;
593 if name == "default" {
594 Ok(raw.default_profile())
595 } else {
596 Ok(raw.profiles.get(name).cloned().unwrap_or_default())
597 }
598}
599
600fn prompt(label: &str, hint: &str, default: Option<&str>) -> Result<String, std::io::Error> {
605 use std::io::{self, Write};
606 let hint_part = if hint.is_empty() {
607 String::new()
608 } else {
609 format!(" {hint}")
610 };
611 let default_part = match default {
612 Some(d) if !d.is_empty() => format!(" [{d}]"),
613 _ => String::new(),
614 };
615 eprint!("{} {label}{hint_part}{default_part}: ", sym_q());
616 io::stderr().flush()?;
617 let mut buf = String::new();
618 io::stdin().read_line(&mut buf)?;
619 let trimmed = buf.trim().to_owned();
620 if trimmed.is_empty() {
621 Ok(default.unwrap_or("").to_owned())
622 } else {
623 Ok(trimmed)
624 }
625}
626
627fn prompt_required(
629 label: &str,
630 hint: &str,
631 default: Option<&str>,
632) -> Result<String, std::io::Error> {
633 loop {
634 let value = prompt(label, hint, default)?;
635 if !value.trim().is_empty() {
636 return Ok(value);
637 }
638 eprintln!(" {} {label} is required.", sym_fail());
639 }
640}
641
642fn sym_q() -> String {
645 if crate::output::use_color() {
646 use owo_colors::OwoColorize;
647 "?".green().bold().to_string()
648 } else {
649 "?".to_owned()
650 }
651}
652
653fn sym_ok() -> String {
654 if crate::output::use_color() {
655 use owo_colors::OwoColorize;
656 "✔".green().to_string()
657 } else {
658 "✔".to_owned()
659 }
660}
661
662fn sym_fail() -> String {
663 if crate::output::use_color() {
664 use owo_colors::OwoColorize;
665 "✖".red().to_string()
666 } else {
667 "✖".to_owned()
668 }
669}
670
671fn sym_dim(s: &str) -> String {
672 if crate::output::use_color() {
673 use owo_colors::OwoColorize;
674 s.dimmed().to_string()
675 } else {
676 s.to_owned()
677 }
678}
679
680fn write_profile_to_config(
685 path: &std::path::Path,
686 profile_name: &str,
687 host: &str,
688 email: Option<&str>,
689 token: &str,
690 auth_type: &str,
691 api_version: u8,
692) -> Result<(), Box<dyn std::error::Error>> {
693 let existing = if path.exists() {
694 std::fs::read_to_string(path)?
695 } else {
696 String::new()
697 };
698
699 let mut doc: toml::Value = if existing.trim().is_empty() {
700 toml::Value::Table(toml::map::Map::new())
701 } else {
702 toml::from_str(&existing)?
703 };
704
705 let root = doc.as_table_mut().expect("config is a TOML table");
706
707 let mut section = toml::map::Map::new();
708 section.insert("host".to_owned(), toml::Value::String(host.to_owned()));
709 if let Some(e) = email {
710 section.insert("email".to_owned(), toml::Value::String(e.to_owned()));
711 }
712 section.insert("token".to_owned(), toml::Value::String(token.to_owned()));
713 if auth_type != "basic" {
714 section.insert(
715 "auth_type".to_owned(),
716 toml::Value::String(auth_type.to_owned()),
717 );
718 section.insert(
719 "api_version".to_owned(),
720 toml::Value::Integer(i64::from(api_version)),
721 );
722 }
723
724 if profile_name == "default" {
725 root.insert("default".to_owned(), toml::Value::Table(section));
726 } else {
727 let profiles = root
728 .entry("profiles")
729 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
730 profiles
731 .as_table_mut()
732 .expect("profiles is a TOML table")
733 .insert(profile_name.to_owned(), toml::Value::Table(section));
734 }
735
736 if let Some(parent) = path.parent() {
737 std::fs::create_dir_all(parent)?;
738 }
739 std::fs::write(path, toml::to_string_pretty(&doc)?)?;
740
741 Ok(())
742}
743
744pub fn remove_profile(profile_name: &str) {
750 let path = config_path();
751
752 if !path.exists() {
753 eprintln!("No config file found at {}", path.display());
754 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
755 }
756
757 let result = (|| -> Result<(), Box<dyn std::error::Error>> {
758 let content = std::fs::read_to_string(&path)?;
759 let mut doc: toml::Value = toml::from_str(&content)?;
760 let root = doc.as_table_mut().ok_or("config is not a TOML table")?;
761
762 let removed = if profile_name == "default" {
763 root.remove("default").is_some()
764 } else {
765 root.get_mut("profiles")
766 .and_then(toml::Value::as_table_mut)
767 .and_then(|t| t.remove(profile_name))
768 .is_some()
769 };
770
771 if !removed {
772 return Err(format!("profile '{profile_name}' not found").into());
773 }
774
775 std::fs::write(&path, toml::to_string_pretty(&doc)?)?;
776 Ok(())
777 })();
778
779 match result {
780 Ok(()) => {
781 eprintln!(" {} Removed profile '{profile_name}'", sym_ok());
782 }
783 Err(e) => {
784 eprintln!(" {} {e}", sym_fail());
785 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
786 }
787 }
788}
789
790const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
791
792fn dc_pat_url(host: Option<&str>) -> String {
797 match host {
798 Some(h) => {
799 let base = if h.starts_with("http://") || h.starts_with("https://") {
800 h.trim_end_matches('/').to_string()
801 } else {
802 format!("https://{}", h.trim_end_matches('/'))
803 };
804 format!("{base}{PAT_PATH}")
805 }
806 None => format!("http://<your-host>{PAT_PATH}"),
807 }
808}
809
810fn mask_token(token: &str) -> String {
815 let n = token.chars().count();
816 if n > 4 {
817 let suffix: String = token.chars().skip(n - 4).collect();
818 format!("***{suffix}")
819 } else {
820 "***".into()
821 }
822}
823
824fn env_var(name: &str) -> Option<String> {
825 std::env::var(name)
826 .ok()
827 .and_then(|value| normalize_value(Some(value)))
828}
829
830fn normalize_value(value: Option<String>) -> Option<String> {
831 value.and_then(|value| {
832 let trimmed = value.trim();
833 if trimmed.is_empty() {
834 None
835 } else {
836 Some(trimmed.to_string())
837 }
838 })
839}
840
841fn normalize_str(value: Option<&str>) -> Option<&str> {
842 value.and_then(|value| {
843 let trimmed = value.trim();
844 if trimmed.is_empty() {
845 None
846 } else {
847 Some(trimmed)
848 }
849 })
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
856 use tempfile::TempDir;
857
858 #[test]
859 fn mask_token_long() {
860 let masked = mask_token("ATATxxx1234abcd");
861 assert!(masked.starts_with("***"));
862 assert!(masked.ends_with("abcd"));
863 }
864
865 #[test]
866 fn mask_token_short() {
867 assert_eq!(mask_token("abc"), "***");
868 }
869
870 #[test]
871 fn mask_token_unicode_safe() {
872 let token = "token-日本語-end";
874 let result = mask_token(token);
875 assert!(result.starts_with("***"));
876 }
877
878 #[test]
879 #[cfg(not(target_os = "windows"))]
880 fn config_path_prefers_xdg_config_home() {
881 let _env = ProcessEnvLock::acquire().unwrap();
882 let dir = TempDir::new().unwrap();
883 let _config_dir = set_config_dir_env(dir.path());
884
885 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
886 }
887
888 #[test]
889 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
890 let _env = ProcessEnvLock::acquire().unwrap();
891 let dir = TempDir::new().unwrap();
892 write_config(
893 dir.path(),
894 r#"
895[default]
896host = "work.atlassian.net"
897email = "me@example.com"
898token = "secret-token"
899"#,
900 )
901 .unwrap();
902
903 let _config_dir = set_config_dir_env(dir.path());
904 let _host = EnvVarGuard::set("JIRA_HOST", " ");
905 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
906 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
907 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
908
909 let cfg = Config::load(None, None, None).unwrap();
910 assert_eq!(cfg.host, "work.atlassian.net");
911 assert_eq!(cfg.email, "me@example.com");
912 assert_eq!(cfg.token, "secret-token");
913 }
914
915 #[test]
916 fn load_accepts_documented_default_section() {
917 let _env = ProcessEnvLock::acquire().unwrap();
918 let dir = TempDir::new().unwrap();
919 write_config(
920 dir.path(),
921 r#"
922[default]
923host = "example.atlassian.net"
924email = "me@example.com"
925token = "secret-token"
926"#,
927 )
928 .unwrap();
929
930 let _config_dir = set_config_dir_env(dir.path());
931 let _host = EnvVarGuard::unset("JIRA_HOST");
932 let _email = EnvVarGuard::unset("JIRA_EMAIL");
933 let _token = EnvVarGuard::unset("JIRA_TOKEN");
934 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
935
936 let cfg = Config::load(None, None, None).unwrap();
937 assert_eq!(cfg.host, "example.atlassian.net");
938 assert_eq!(cfg.email, "me@example.com");
939 assert_eq!(cfg.token, "secret-token");
940 }
941
942 #[test]
943 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
944 let _env = ProcessEnvLock::acquire().unwrap();
945 let dir = TempDir::new().unwrap();
946 let _config_dir = set_config_dir_env(dir.path());
947 let _host = EnvVarGuard::set("JIRA_HOST", "");
948 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
949 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
950 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
951
952 let err = Config::load(None, None, None).unwrap_err();
953 assert!(matches!(err, ApiError::InvalidInput(_)));
954 assert!(err.to_string().contains("No Jira host configured"));
955 }
956
957 #[test]
958 fn permission_guidance_matches_platform() {
959 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
960
961 #[cfg(target_os = "windows")]
962 assert!(guidance.contains("AppData"));
963
964 #[cfg(not(target_os = "windows"))]
965 assert!(guidance.starts_with("chmod 600 "));
966 }
967
968 #[test]
971 fn load_env_host_overrides_file() {
972 let _env = ProcessEnvLock::acquire().unwrap();
973 let dir = TempDir::new().unwrap();
974 write_config(
975 dir.path(),
976 r#"
977[default]
978host = "file.atlassian.net"
979email = "me@example.com"
980token = "tok"
981"#,
982 )
983 .unwrap();
984
985 let _config_dir = set_config_dir_env(dir.path());
986 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
987 let _email = EnvVarGuard::unset("JIRA_EMAIL");
988 let _token = EnvVarGuard::unset("JIRA_TOKEN");
989 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
990
991 let cfg = Config::load(None, None, None).unwrap();
992 assert_eq!(cfg.host, "env.atlassian.net");
993 }
994
995 #[test]
996 fn load_cli_host_arg_overrides_env_and_file() {
997 let _env = ProcessEnvLock::acquire().unwrap();
998 let dir = TempDir::new().unwrap();
999 write_config(
1000 dir.path(),
1001 r#"
1002[default]
1003host = "file.atlassian.net"
1004email = "me@example.com"
1005token = "tok"
1006"#,
1007 )
1008 .unwrap();
1009
1010 let _config_dir = set_config_dir_env(dir.path());
1011 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1012 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1013 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1014 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1015
1016 let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
1017 assert_eq!(cfg.host, "cli.atlassian.net");
1018 }
1019
1020 #[test]
1023 fn load_missing_token_returns_error() {
1024 let _env = ProcessEnvLock::acquire().unwrap();
1025 let dir = TempDir::new().unwrap();
1026 let _config_dir = set_config_dir_env(dir.path());
1027 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1028 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1029 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1030 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1031
1032 let err = Config::load(None, None, None).unwrap_err();
1033 assert!(matches!(err, ApiError::InvalidInput(_)));
1034 assert!(err.to_string().contains("No API token"));
1035 }
1036
1037 #[test]
1038 fn load_missing_email_for_basic_auth_returns_error() {
1039 let _env = ProcessEnvLock::acquire().unwrap();
1040 let dir = TempDir::new().unwrap();
1041 let _config_dir = set_config_dir_env(dir.path());
1042 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1043 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1044 let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
1045 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1046 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1047
1048 let err = Config::load(None, None, None).unwrap_err();
1049 assert!(matches!(err, ApiError::InvalidInput(_)));
1050 assert!(err.to_string().contains("No email configured"));
1051 }
1052
1053 #[test]
1054 fn load_invalid_toml_returns_error() {
1055 let _env = ProcessEnvLock::acquire().unwrap();
1056 let dir = TempDir::new().unwrap();
1057 write_config(dir.path(), "host = [invalid toml").unwrap();
1058
1059 let _config_dir = set_config_dir_env(dir.path());
1060 let _host = EnvVarGuard::unset("JIRA_HOST");
1061 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1062 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1063
1064 let err = Config::load(None, None, None).unwrap_err();
1065 assert!(matches!(err, ApiError::Other(_)));
1066 assert!(err.to_string().contains("parse"));
1067 }
1068
1069 #[test]
1072 fn load_pat_auth_does_not_require_email() {
1073 let _env = ProcessEnvLock::acquire().unwrap();
1074 let dir = TempDir::new().unwrap();
1075 write_config(
1076 dir.path(),
1077 r#"
1078[default]
1079host = "jira.corp.com"
1080token = "my-pat-token"
1081auth_type = "pat"
1082api_version = 2
1083"#,
1084 )
1085 .unwrap();
1086
1087 let _config_dir = set_config_dir_env(dir.path());
1088 let _host = EnvVarGuard::unset("JIRA_HOST");
1089 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1090 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1091 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1092 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1093
1094 let cfg = Config::load(None, None, None).unwrap();
1095 assert_eq!(cfg.auth_type, AuthType::Pat);
1096 assert_eq!(cfg.api_version, 2);
1097 assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
1098 }
1099
1100 #[test]
1101 fn load_jira_auth_type_env_pat_overrides_basic() {
1102 let _env = ProcessEnvLock::acquire().unwrap();
1103 let dir = TempDir::new().unwrap();
1104 write_config(
1105 dir.path(),
1106 r#"
1107[default]
1108host = "jira.corp.com"
1109email = "me@example.com"
1110token = "tok"
1111auth_type = "basic"
1112"#,
1113 )
1114 .unwrap();
1115
1116 let _config_dir = set_config_dir_env(dir.path());
1117 let _host = EnvVarGuard::unset("JIRA_HOST");
1118 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1119 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1120 let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
1121 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1122
1123 let cfg = Config::load(None, None, None).unwrap();
1124 assert_eq!(cfg.auth_type, AuthType::Pat);
1125 }
1126
1127 #[test]
1128 fn load_jira_api_version_env_overrides_default() {
1129 let _env = ProcessEnvLock::acquire().unwrap();
1130 let dir = TempDir::new().unwrap();
1131 let _config_dir = set_config_dir_env(dir.path());
1132 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1133 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1134 let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
1135 let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
1136 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1137 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1138
1139 let cfg = Config::load(None, None, None).unwrap();
1140 assert_eq!(cfg.api_version, 2);
1141 }
1142
1143 #[test]
1146 fn load_profile_arg_selects_named_section() {
1147 let _env = ProcessEnvLock::acquire().unwrap();
1148 let dir = TempDir::new().unwrap();
1149 write_config(
1150 dir.path(),
1151 r#"
1152[default]
1153host = "default.atlassian.net"
1154email = "default@example.com"
1155token = "default-tok"
1156
1157[profiles.work]
1158host = "work.atlassian.net"
1159email = "me@work.com"
1160token = "work-tok"
1161"#,
1162 )
1163 .unwrap();
1164
1165 let _config_dir = set_config_dir_env(dir.path());
1166 let _host = EnvVarGuard::unset("JIRA_HOST");
1167 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1168 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1169 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1170
1171 let cfg = Config::load(None, None, Some("work".into())).unwrap();
1172 assert_eq!(cfg.host, "work.atlassian.net");
1173 assert_eq!(cfg.email, "me@work.com");
1174 assert_eq!(cfg.token, "work-tok");
1175 }
1176
1177 #[test]
1178 fn load_jira_profile_env_selects_named_section() {
1179 let _env = ProcessEnvLock::acquire().unwrap();
1180 let dir = TempDir::new().unwrap();
1181 write_config(
1182 dir.path(),
1183 r#"
1184[default]
1185host = "default.atlassian.net"
1186email = "default@example.com"
1187token = "default-tok"
1188
1189[profiles.staging]
1190host = "staging.atlassian.net"
1191email = "me@staging.com"
1192token = "staging-tok"
1193"#,
1194 )
1195 .unwrap();
1196
1197 let _config_dir = set_config_dir_env(dir.path());
1198 let _host = EnvVarGuard::unset("JIRA_HOST");
1199 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1200 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1201 let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
1202
1203 let cfg = Config::load(None, None, None).unwrap();
1204 assert_eq!(cfg.host, "staging.atlassian.net");
1205 }
1206
1207 #[test]
1208 fn load_unknown_profile_returns_descriptive_error() {
1209 let _env = ProcessEnvLock::acquire().unwrap();
1210 let dir = TempDir::new().unwrap();
1211 write_config(
1212 dir.path(),
1213 r#"
1214[profiles.alpha]
1215host = "alpha.atlassian.net"
1216email = "me@alpha.com"
1217token = "alpha-tok"
1218"#,
1219 )
1220 .unwrap();
1221
1222 let _config_dir = set_config_dir_env(dir.path());
1223 let _host = EnvVarGuard::unset("JIRA_HOST");
1224 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1225 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1226
1227 let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
1228 assert!(matches!(err, ApiError::Other(_)));
1229 let msg = err.to_string();
1230 assert!(
1231 msg.contains("nonexistent"),
1232 "error should name the bad profile"
1233 );
1234 assert!(
1235 msg.contains("alpha"),
1236 "error should list available profiles"
1237 );
1238 }
1239
1240 #[test]
1243 fn show_json_output_includes_host_and_masked_token() {
1244 let _env = ProcessEnvLock::acquire().unwrap();
1245 let dir = TempDir::new().unwrap();
1246 write_config(
1247 dir.path(),
1248 r#"
1249[default]
1250host = "show-test.atlassian.net"
1251email = "me@example.com"
1252token = "supersecrettoken"
1253"#,
1254 )
1255 .unwrap();
1256
1257 let _config_dir = set_config_dir_env(dir.path());
1258 let _host = EnvVarGuard::unset("JIRA_HOST");
1259 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1260 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1261 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1262
1263 let out = crate::output::OutputConfig::new(true, true);
1264 show(&out, None, None, None).unwrap();
1266 }
1267
1268 #[test]
1269 fn show_text_output_renders_without_error() {
1270 let _env = ProcessEnvLock::acquire().unwrap();
1271 let dir = TempDir::new().unwrap();
1272 write_config(
1273 dir.path(),
1274 r#"
1275[default]
1276host = "show-test.atlassian.net"
1277email = "me@example.com"
1278token = "supersecrettoken"
1279"#,
1280 )
1281 .unwrap();
1282
1283 let _config_dir = set_config_dir_env(dir.path());
1284 let _host = EnvVarGuard::unset("JIRA_HOST");
1285 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1286 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1287 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1288
1289 let out = crate::output::OutputConfig::new(false, true);
1290 show(&out, None, None, None).unwrap();
1291 }
1292
1293 #[tokio::test]
1296 async fn init_json_output_includes_example_and_paths() {
1297 let out = crate::output::OutputConfig::new(true, true);
1298 init(&out, Some("jira.corp.com")).await;
1300 }
1301
1302 #[tokio::test]
1305 async fn init_non_interactive_prints_message_without_error() {
1306 let out = crate::output::OutputConfig {
1307 json: false,
1308 quiet: false,
1309 };
1310 init(&out, None).await;
1312 }
1313
1314 #[test]
1315 fn write_profile_to_config_creates_default_profile() {
1316 let dir = TempDir::new().unwrap();
1317 let path = dir.path().join("jira").join("config.toml");
1318
1319 write_profile_to_config(
1320 &path,
1321 "default",
1322 "acme.atlassian.net",
1323 Some("me@acme.com"),
1324 "secret",
1325 "basic",
1326 3,
1327 )
1328 .unwrap();
1329
1330 let content = std::fs::read_to_string(&path).unwrap();
1331 assert!(content.contains("acme.atlassian.net"));
1332 assert!(content.contains("me@acme.com"));
1333 assert!(content.contains("secret"));
1334 assert!(!content.contains("auth_type"));
1336 }
1337
1338 #[test]
1339 fn write_profile_to_config_creates_named_pat_profile() {
1340 let dir = TempDir::new().unwrap();
1341 let path = dir.path().join("config.toml");
1342
1343 write_profile_to_config(&path, "dc", "jira.corp.com", None, "pattoken", "pat", 2).unwrap();
1344
1345 let content = std::fs::read_to_string(&path).unwrap();
1346 assert!(content.contains("[profiles.dc]"));
1347 assert!(content.contains("jira.corp.com"));
1348 assert!(content.contains("pattoken"));
1349 assert!(content.contains("auth_type"));
1350 assert!(content.contains("api_version"));
1351 assert!(!content.contains("email"));
1352 }
1353
1354 #[test]
1355 fn write_profile_to_config_preserves_other_profiles() {
1356 let dir = TempDir::new().unwrap();
1357 let path = dir.path().join("config.toml");
1358
1359 std::fs::write(
1361 &path,
1362 "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
1363 )
1364 .unwrap();
1365
1366 write_profile_to_config(
1368 &path,
1369 "work",
1370 "work.atlassian.net",
1371 Some("w@work.com"),
1372 "tok2",
1373 "basic",
1374 3,
1375 )
1376 .unwrap();
1377
1378 let content = std::fs::read_to_string(&path).unwrap();
1379 assert!(
1380 content.contains("first.atlassian.net"),
1381 "default profile must be preserved"
1382 );
1383 assert!(
1384 content.contains("work.atlassian.net"),
1385 "new profile must be written"
1386 );
1387 }
1388
1389 #[test]
1392 fn remove_profile_removes_default_section() {
1393 let _env = ProcessEnvLock::acquire().unwrap();
1394 let dir = TempDir::new().unwrap();
1395 let path = write_config(
1396 dir.path(),
1397 "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
1398 )
1399 .unwrap();
1400
1401 let _config_dir = set_config_dir_env(dir.path());
1402 remove_profile("default");
1403
1404 let content = std::fs::read_to_string(&path).unwrap();
1405 assert!(!content.contains("[default]"));
1406 assert!(!content.contains("acme.atlassian.net"));
1407 }
1408
1409 #[test]
1410 fn remove_profile_removes_named_profile_preserves_others() {
1411 let _env = ProcessEnvLock::acquire().unwrap();
1412 let dir = TempDir::new().unwrap();
1413 let path = write_config(
1414 dir.path(),
1415 "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
1416 [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
1417 )
1418 .unwrap();
1419
1420 let _config_dir = set_config_dir_env(dir.path());
1421 remove_profile("work");
1422
1423 let content = std::fs::read_to_string(&path).unwrap();
1424 assert!(
1425 !content.contains("work.atlassian.net"),
1426 "work profile must be gone"
1427 );
1428 assert!(
1429 content.contains("first.atlassian.net"),
1430 "default profile must be preserved"
1431 );
1432 }
1433
1434 #[test]
1435 fn remove_profile_last_named_profile_leaves_default_intact() {
1436 let _env = ProcessEnvLock::acquire().unwrap();
1437 let dir = TempDir::new().unwrap();
1438 let path = write_config(
1439 dir.path(),
1440 "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
1441 [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
1442 )
1443 .unwrap();
1444
1445 let _config_dir = set_config_dir_env(dir.path());
1446 remove_profile("staging");
1447
1448 let content = std::fs::read_to_string(&path).unwrap();
1449 assert!(
1450 !content.contains("staging.atlassian.net"),
1451 "staging must be gone"
1452 );
1453 assert!(
1454 content.contains("acme.atlassian.net"),
1455 "default must be preserved"
1456 );
1457 }
1458
1459 #[test]
1462 fn dc_pat_url_without_host_returns_placeholder() {
1463 let url = dc_pat_url(None);
1464 assert!(url.starts_with("http://<your-host>"));
1465 assert!(url.contains(PAT_PATH));
1466 }
1467
1468 #[test]
1469 fn dc_pat_url_bare_host_adds_https_scheme() {
1470 let url = dc_pat_url(Some("jira.corp.com"));
1471 assert!(url.starts_with("https://jira.corp.com"));
1472 assert!(url.contains(PAT_PATH));
1473 }
1474
1475 #[test]
1476 fn dc_pat_url_host_with_https_scheme_is_preserved() {
1477 let url = dc_pat_url(Some("https://jira.corp.com/"));
1478 assert!(url.starts_with("https://jira.corp.com"));
1479 assert!(!url.contains("https://https://"));
1480 assert!(url.contains(PAT_PATH));
1481 }
1482
1483 #[test]
1484 fn dc_pat_url_host_with_http_scheme_is_preserved() {
1485 let url = dc_pat_url(Some("http://localhost:8080"));
1486 assert!(url.starts_with("http://localhost:8080"));
1487 assert!(url.contains(PAT_PATH));
1488 }
1489}