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