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 pub read_only: Option<bool>,
18}
19
20#[derive(Debug, Deserialize, Default)]
21struct RawConfig {
22 #[serde(default)]
23 default: ProfileConfig,
24 #[serde(default)]
25 profiles: BTreeMap<String, ProfileConfig>,
26 host: Option<String>,
27 email: Option<String>,
28 token: Option<String>,
29 auth_type: Option<String>,
30 api_version: Option<u8>,
31 read_only: Option<bool>,
32}
33
34impl RawConfig {
35 fn default_profile(&self) -> ProfileConfig {
36 ProfileConfig {
37 host: self.default.host.clone().or_else(|| self.host.clone()),
38 email: self.default.email.clone().or_else(|| self.email.clone()),
39 token: self.default.token.clone().or_else(|| self.token.clone()),
40 auth_type: self
41 .default
42 .auth_type
43 .clone()
44 .or_else(|| self.auth_type.clone()),
45 api_version: self.default.api_version.or(self.api_version),
46 read_only: self.default.read_only.or(self.read_only),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct Config {
54 pub host: String,
55 pub email: String,
56 pub token: String,
57 pub auth_type: AuthType,
58 pub api_version: u8,
59 pub read_only: bool,
60}
61
62impl Config {
63 pub fn load(
69 host_arg: Option<String>,
70 email_arg: Option<String>,
71 profile_arg: Option<String>,
72 ) -> Result<Self, ApiError> {
73 let file_profile = load_file_profile(profile_arg.as_deref())?;
74
75 let host = normalize_value(host_arg)
76 .or_else(|| env_var("JIRA_HOST"))
77 .or_else(|| normalize_value(file_profile.host))
78 .ok_or_else(|| {
79 ApiError::InvalidInput(
80 "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
81 )
82 })?;
83
84 let token = env_var("JIRA_TOKEN")
85 .or_else(|| normalize_value(file_profile.token.clone()))
86 .ok_or_else(|| {
87 ApiError::InvalidInput(
88 "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
89 )
90 })?;
91
92 let auth_type = env_var("JIRA_AUTH_TYPE")
93 .as_deref()
94 .map(|v| {
95 if v.eq_ignore_ascii_case("pat") {
96 AuthType::Pat
97 } else {
98 AuthType::Basic
99 }
100 })
101 .or_else(|| {
102 file_profile.auth_type.as_deref().map(|v| {
103 if v.eq_ignore_ascii_case("pat") {
104 AuthType::Pat
105 } else {
106 AuthType::Basic
107 }
108 })
109 })
110 .unwrap_or_default();
111
112 let api_version = env_var("JIRA_API_VERSION")
113 .and_then(|v| v.parse::<u8>().ok())
114 .or(file_profile.api_version)
115 .unwrap_or(3);
116
117 let email = normalize_value(email_arg)
119 .or_else(|| env_var("JIRA_EMAIL"))
120 .or_else(|| normalize_value(file_profile.email));
121
122 let email = match auth_type {
123 AuthType::Basic => email.ok_or_else(|| {
124 ApiError::InvalidInput(
125 "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
126 )
127 })?,
128 AuthType::Pat => email.unwrap_or_default(),
129 };
130
131 let read_only = env_var("JIRA_READ_ONLY")
132 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "on"))
133 .or(file_profile.read_only)
134 .unwrap_or(false);
135
136 Ok(Self {
137 host,
138 email,
139 token,
140 auth_type,
141 api_version,
142 read_only,
143 })
144 }
145}
146
147fn config_path() -> PathBuf {
148 config_dir()
149 .unwrap_or_else(|| PathBuf::from(".config"))
150 .join("jira")
151 .join("config.toml")
152}
153
154pub fn schema_config_path() -> String {
155 config_path().display().to_string()
156}
157
158pub fn schema_config_path_description() -> &'static str {
159 #[cfg(target_os = "windows")]
160 {
161 "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
162 }
163
164 #[cfg(not(target_os = "windows"))]
165 {
166 "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
167 }
168}
169
170pub fn recommended_permissions(path: &std::path::Path) -> String {
171 #[cfg(target_os = "windows")]
172 {
173 format!(
174 "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
175 path.display()
176 )
177 }
178
179 #[cfg(not(target_os = "windows"))]
180 {
181 format!("chmod 600 {}", path.display())
182 }
183}
184
185pub fn schema_recommended_permissions_example() -> &'static str {
186 #[cfg(target_os = "windows")]
187 {
188 "Keep the file in your per-user %APPDATA% directory and out of shared folders."
189 }
190
191 #[cfg(not(target_os = "windows"))]
192 {
193 "chmod 600 /path/to/config.toml"
194 }
195}
196
197fn config_dir() -> Option<PathBuf> {
198 #[cfg(target_os = "windows")]
199 {
200 dirs::config_dir()
201 }
202
203 #[cfg(not(target_os = "windows"))]
204 {
205 std::env::var_os("XDG_CONFIG_HOME")
206 .filter(|value| !value.is_empty())
207 .map(PathBuf::from)
208 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
209 }
210}
211
212fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
213 let path = config_path();
214 let content = match std::fs::read_to_string(&path) {
215 Ok(c) => c,
216 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
217 Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
218 };
219
220 let raw: RawConfig = toml::from_str(&content)
221 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
222
223 let profile_name = normalize_str(profile)
224 .map(str::to_owned)
225 .or_else(|| env_var("JIRA_PROFILE"));
226
227 match profile_name {
228 Some(name) => {
229 let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
231 raw.profiles.get(&name).cloned().ok_or_else(|| {
232 ApiError::Other(format!(
233 "Profile '{name}' not found in config. Available: {}",
234 available.join(", ")
235 ))
236 })
237 }
238 None => Ok(raw.default_profile()),
239 }
240}
241
242pub fn show(
244 out: &OutputConfig,
245 host_arg: Option<String>,
246 email_arg: Option<String>,
247 profile_arg: Option<String>,
248) -> Result<(), ApiError> {
249 let path = config_path();
250 let cfg = Config::load(host_arg, email_arg, profile_arg)?;
251 let masked = mask_token(&cfg.token);
252
253 if out.json {
254 out.print_data(
255 &serde_json::to_string_pretty(&serde_json::json!({
256 "configPath": path,
257 "host": cfg.host,
258 "email": cfg.email,
259 "tokenMasked": masked,
260 }))
261 .expect("failed to serialize JSON"),
262 );
263 } else {
264 out.print_message(&format!("Config file: {}", path.display()));
265 out.print_data(&format!(
266 "host: {}\nemail: {}\ntoken: {masked}",
267 cfg.host, cfg.email
268 ));
269 }
270 Ok(())
271}
272
273pub async fn init(out: &OutputConfig, host: Option<&str>) {
280 if out.json {
281 init_json(out, host);
282 return;
283 }
284
285 use std::io::IsTerminal;
286 if !std::io::stdin().is_terminal() {
287 out.print_message(
288 "Run `jira init` in an interactive terminal to configure credentials, \
289 or use `jira init --json` for setup instructions.",
290 );
291 return;
292 }
293
294 if let Err(e) = init_interactive(host).await {
295 eprintln!("{} {e}", sym_fail());
296 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
297 }
298}
299
300fn init_json(out: &OutputConfig, host: Option<&str>) {
301 let path = config_path();
302 let path_resolution = schema_config_path_description();
303 let permission_advice = recommended_permissions(&path);
304 let example = serde_json::json!({
305 "default": {
306 "host": "mycompany.atlassian.net",
307 "email": "me@example.com",
308 "token": "your-api-token",
309 "auth_type": "basic",
310 "api_version": 3,
311 },
312 "profiles": {
313 "work": {
314 "host": "work.atlassian.net",
315 "email": "me@work.com",
316 "token": "work-token",
317 },
318 "datacenter": {
319 "host": "jira.mycompany.com",
320 "token": "your-personal-access-token",
321 "auth_type": "pat",
322 "api_version": 2,
323 }
324 }
325 });
326
327 const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
328 let pat_url = dc_pat_url(host);
329
330 out.print_data(
331 &serde_json::to_string_pretty(&serde_json::json!({
332 "configPath": path,
333 "pathResolution": path_resolution,
334 "configExists": path.exists(),
335 "tokenInstructions": CLOUD_TOKEN_URL,
336 "dcPatInstructions": pat_url,
337 "recommendedPermissions": permission_advice,
338 "example": example,
339 }))
340 .expect("failed to serialize JSON"),
341 );
342}
343
344async fn init_interactive(prefill_host: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
345 let sep = sym_dim("──────────────");
346 eprintln!("Jira CLI Setup");
347 eprintln!("{sep}");
348
349 let path = config_path();
350
351 let (target_name, existing): (Option<String>, Option<ProfileConfig>) = if path.exists() {
357 let profiles = list_profile_names(&path)?;
358
359 eprintln!();
362 eprintln!(
363 " {} {}",
364 sym_dim("Config:"),
365 sym_dim(&path.display().to_string())
366 );
367 eprintln!();
368 eprintln!(" {}:", sym_dim("Profiles"));
369 for name in &profiles {
370 let host = read_raw_profile(&path, name)
371 .ok()
372 .and_then(|p| p.host)
373 .unwrap_or_default();
374 eprintln!(" {} {} {}", sym_dim("•"), name, sym_dim(&host));
375 }
376 eprintln!();
377
378 let action = prompt("Action", "[update/add]", Some("update"))?;
379 eprintln!();
380
381 if !action.trim().eq_ignore_ascii_case("add") {
382 let default = profiles.first().map(String::as_str).unwrap_or("default");
383 let raw = if profiles.len() > 1 {
384 prompt("Profile", "", Some(default))?
385 } else {
386 default.to_owned()
387 };
388 let name = if raw.trim().is_empty() {
389 default.to_owned()
390 } else {
391 raw.trim().to_owned()
392 };
393 let cfg = read_raw_profile(&path, &name)?;
394 if profiles.len() > 1 {
395 eprintln!();
396 }
397 (Some(name), Some(cfg))
398 } else {
399 (None, None)
400 }
401 } else {
402 eprintln!();
404 (Some("default".to_owned()), None)
405 };
406
407 let is_cloud = if let Some(ref p) = existing {
409 p.auth_type.as_deref() != Some("pat")
410 } else {
411 let t = prompt("Type", sym_dim("[cloud/dc]").as_str(), Some("cloud"))?;
412 eprintln!();
413 !t.trim().eq_ignore_ascii_case("dc")
414 };
415
416 let host = if is_cloud {
418 let default_sub = existing
419 .as_ref()
420 .and_then(|p| p.host.clone())
421 .as_deref()
422 .or(prefill_host)
423 .map(|h| h.trim_end_matches(".atlassian.net").to_owned());
424 let raw = prompt_required("Subdomain", "", default_sub.as_deref())?;
425 let sub = raw.trim().trim_end_matches(".atlassian.net");
426 format!("{sub}.atlassian.net")
427 } else {
428 let default = existing
429 .as_ref()
430 .and_then(|p| p.host.clone())
431 .or_else(|| prefill_host.map(str::to_owned));
432 prompt_required("Host", "", default.as_deref())?
433 };
434
435 let (email, token, auth_type, api_version): (Option<String>, String, &str, u8) = if is_cloud {
437 const CLOUD_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
438 let default_email = existing.as_ref().and_then(|p| p.email.clone());
439 let email = prompt_required("Email", "", default_email.as_deref())?;
440 eprintln!(" {}", sym_dim(&format!("→ {CLOUD_URL}")));
441 let token_hint = if existing.as_ref().and_then(|p| p.token.as_ref()).is_some() {
442 "(Enter to keep)"
443 } else {
444 ""
445 };
446 let raw = prompt("Token", token_hint, None)?;
447 let token = if raw.trim().is_empty() {
448 existing
449 .as_ref()
450 .and_then(|p| p.token.clone())
451 .ok_or("No existing token — please enter a token.")?
452 } else {
453 raw
454 };
455 (Some(email), token, "basic", 3)
456 } else {
457 let pat_url = dc_pat_url(Some(&host));
458 eprintln!(" {}", sym_dim(&format!("→ {pat_url}")));
459 let token_hint = if existing.as_ref().and_then(|p| p.token.as_ref()).is_some() {
460 "(Enter to keep)"
461 } else {
462 ""
463 };
464 let raw = prompt("Token", token_hint, None)?;
465 let token = if raw.trim().is_empty() {
466 existing
467 .as_ref()
468 .and_then(|p| p.token.clone())
469 .ok_or("No existing token — please enter a token.")?
470 } else {
471 raw
472 };
473 let default_ver = existing
474 .as_ref()
475 .and_then(|p| p.api_version.map(|v| v.to_string()))
476 .unwrap_or_else(|| "2".to_owned());
477 let ver_str = prompt("API version", "", Some(&default_ver))?;
478 let api_version: u8 = ver_str.trim().parse().unwrap_or(2);
479 (None, token, "pat", api_version)
480 };
481
482 use std::io::Write;
484 eprintln!();
485 eprint!(" Verifying credentials...");
486 std::io::stderr().flush().ok();
487
488 let auth_type_enum = if auth_type == "pat" {
489 AuthType::Pat
490 } else {
491 AuthType::Basic
492 };
493
494 let verified = match crate::api::client::JiraClient::new(
495 &host,
496 email.as_deref().unwrap_or(""),
497 &token,
498 auth_type_enum,
499 api_version,
500 ) {
501 Err(e) => {
502 eprintln!(" {} {e}", sym_fail());
503 return Err(e.into());
504 }
505 Ok(client) => match client.get_myself().await {
506 Ok(myself) => {
507 eprintln!(" {} Authenticated as {}", sym_ok(), myself.display_name);
508 true
509 }
510 Err(e) => {
511 eprintln!(" {} {e}", sym_fail());
512 eprintln!();
513 let save = prompt("Save config anyway?", sym_dim("[y/N]").as_str(), Some("n"))?;
514 save.trim().eq_ignore_ascii_case("y")
515 }
516 },
517 };
518
519 if !verified {
520 eprintln!();
521 eprintln!("{sep}");
522 return Ok(());
523 }
524
525 let profile_name = match target_name {
527 Some(name) => name,
528 None => {
529 eprintln!();
530 let raw = prompt_required("Profile name", "", Some("default"))?;
531 if raw.trim().is_empty() {
532 "default".to_owned()
533 } else {
534 raw.trim().to_owned()
535 }
536 }
537 };
538
539 write_profile_to_config(
541 &path,
542 &profile_name,
543 &host,
544 email.as_deref(),
545 &token,
546 auth_type,
547 api_version,
548 )?;
549
550 #[cfg(unix)]
551 {
552 use std::os::unix::fs::PermissionsExt;
553 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
554 }
555
556 eprintln!();
557 eprintln!(" {} Config written to {}", sym_ok(), path.display());
558 eprintln!("{sep}");
559 if profile_name == "default" {
560 eprintln!(" Run: jira projects list");
561 } else {
562 eprintln!(" Run: jira --profile {profile_name} projects list");
563 }
564 eprintln!();
565
566 Ok(())
567}
568
569fn list_profile_names(path: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
571 let content = std::fs::read_to_string(path)?;
572 let doc: toml::Value = toml::from_str(&content)?;
573 let table = doc.as_table().ok_or("config is not a TOML table")?;
574
575 let mut names = Vec::new();
576 if table.contains_key("default") {
577 names.push("default".to_owned());
578 }
579 if let Some(profiles) = table.get("profiles").and_then(toml::Value::as_table) {
580 for name in profiles.keys() {
581 names.push(name.clone());
582 }
583 }
584 Ok(names)
585}
586
587fn read_raw_profile(
589 path: &std::path::Path,
590 name: &str,
591) -> Result<ProfileConfig, Box<dyn std::error::Error>> {
592 let content = std::fs::read_to_string(path)?;
593 let raw: RawConfig = toml::from_str(&content)?;
594 if name == "default" {
595 Ok(raw.default_profile())
596 } else {
597 Ok(raw.profiles.get(name).cloned().unwrap_or_default())
598 }
599}
600
601fn prompt(label: &str, hint: &str, default: Option<&str>) -> Result<String, std::io::Error> {
606 use std::io::{self, Write};
607 let hint_part = if hint.is_empty() {
608 String::new()
609 } else {
610 format!(" {hint}")
611 };
612 let default_part = match default {
613 Some(d) if !d.is_empty() => format!(" [{d}]"),
614 _ => String::new(),
615 };
616 eprint!("{} {label}{hint_part}{default_part}: ", sym_q());
617 io::stderr().flush()?;
618 let mut buf = String::new();
619 io::stdin().read_line(&mut buf)?;
620 let trimmed = buf.trim().to_owned();
621 if trimmed.is_empty() {
622 Ok(default.unwrap_or("").to_owned())
623 } else {
624 Ok(trimmed)
625 }
626}
627
628fn prompt_required(
630 label: &str,
631 hint: &str,
632 default: Option<&str>,
633) -> Result<String, std::io::Error> {
634 loop {
635 let value = prompt(label, hint, default)?;
636 if !value.trim().is_empty() {
637 return Ok(value);
638 }
639 eprintln!(" {} {label} is required.", sym_fail());
640 }
641}
642
643fn sym_q() -> String {
646 if crate::output::use_color() {
647 use owo_colors::OwoColorize;
648 "?".green().bold().to_string()
649 } else {
650 "?".to_owned()
651 }
652}
653
654fn sym_ok() -> String {
655 if crate::output::use_color() {
656 use owo_colors::OwoColorize;
657 "✔".green().to_string()
658 } else {
659 "✔".to_owned()
660 }
661}
662
663fn sym_fail() -> String {
664 if crate::output::use_color() {
665 use owo_colors::OwoColorize;
666 "✖".red().to_string()
667 } else {
668 "✖".to_owned()
669 }
670}
671
672fn sym_dim(s: &str) -> String {
673 if crate::output::use_color() {
674 use owo_colors::OwoColorize;
675 s.dimmed().to_string()
676 } else {
677 s.to_owned()
678 }
679}
680
681fn write_profile_to_config(
686 path: &std::path::Path,
687 profile_name: &str,
688 host: &str,
689 email: Option<&str>,
690 token: &str,
691 auth_type: &str,
692 api_version: u8,
693) -> Result<(), Box<dyn std::error::Error>> {
694 let existing = if path.exists() {
695 std::fs::read_to_string(path)?
696 } else {
697 String::new()
698 };
699
700 let mut doc: toml::Value = if existing.trim().is_empty() {
701 toml::Value::Table(toml::map::Map::new())
702 } else {
703 toml::from_str(&existing)?
704 };
705
706 let root = doc.as_table_mut().expect("config is a TOML table");
707
708 let mut section = toml::map::Map::new();
709 section.insert("host".to_owned(), toml::Value::String(host.to_owned()));
710 if let Some(e) = email {
711 section.insert("email".to_owned(), toml::Value::String(e.to_owned()));
712 }
713 section.insert("token".to_owned(), toml::Value::String(token.to_owned()));
714 if auth_type != "basic" {
715 section.insert(
716 "auth_type".to_owned(),
717 toml::Value::String(auth_type.to_owned()),
718 );
719 section.insert(
720 "api_version".to_owned(),
721 toml::Value::Integer(i64::from(api_version)),
722 );
723 }
724
725 if profile_name == "default" {
726 root.insert("default".to_owned(), toml::Value::Table(section));
727 } else {
728 let profiles = root
729 .entry("profiles")
730 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
731 profiles
732 .as_table_mut()
733 .expect("profiles is a TOML table")
734 .insert(profile_name.to_owned(), toml::Value::Table(section));
735 }
736
737 if let Some(parent) = path.parent() {
738 std::fs::create_dir_all(parent)?;
739 }
740 std::fs::write(path, toml::to_string_pretty(&doc)?)?;
741
742 Ok(())
743}
744
745pub fn remove_profile(profile_name: &str) {
751 let path = config_path();
752
753 if !path.exists() {
754 eprintln!("No config file found at {}", path.display());
755 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
756 }
757
758 let result = (|| -> Result<(), Box<dyn std::error::Error>> {
759 let content = std::fs::read_to_string(&path)?;
760 let mut doc: toml::Value = toml::from_str(&content)?;
761 let root = doc.as_table_mut().ok_or("config is not a TOML table")?;
762
763 let removed = if profile_name == "default" {
764 root.remove("default").is_some()
765 } else {
766 root.get_mut("profiles")
767 .and_then(toml::Value::as_table_mut)
768 .and_then(|t| t.remove(profile_name))
769 .is_some()
770 };
771
772 if !removed {
773 return Err(format!("profile '{profile_name}' not found").into());
774 }
775
776 std::fs::write(&path, toml::to_string_pretty(&doc)?)?;
777 Ok(())
778 })();
779
780 match result {
781 Ok(()) => {
782 eprintln!(" {} Removed profile '{profile_name}'", sym_ok());
783 }
784 Err(e) => {
785 eprintln!(" {} {e}", sym_fail());
786 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
787 }
788 }
789}
790
791const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
792
793fn dc_pat_url(host: Option<&str>) -> String {
798 match host {
799 Some(h) => {
800 let base = if h.starts_with("http://") || h.starts_with("https://") {
801 h.trim_end_matches('/').to_string()
802 } else {
803 format!("https://{}", h.trim_end_matches('/'))
804 };
805 format!("{base}{PAT_PATH}")
806 }
807 None => format!("http://<your-host>{PAT_PATH}"),
808 }
809}
810
811fn mask_token(token: &str) -> String {
816 let n = token.chars().count();
817 if n > 4 {
818 let suffix: String = token.chars().skip(n - 4).collect();
819 format!("***{suffix}")
820 } else {
821 "***".into()
822 }
823}
824
825fn env_var(name: &str) -> Option<String> {
826 std::env::var(name)
827 .ok()
828 .and_then(|value| normalize_value(Some(value)))
829}
830
831fn normalize_value(value: Option<String>) -> Option<String> {
832 value.and_then(|value| {
833 let trimmed = value.trim();
834 if trimmed.is_empty() {
835 None
836 } else {
837 Some(trimmed.to_string())
838 }
839 })
840}
841
842fn normalize_str(value: Option<&str>) -> Option<&str> {
843 value.and_then(|value| {
844 let trimmed = value.trim();
845 if trimmed.is_empty() {
846 None
847 } else {
848 Some(trimmed)
849 }
850 })
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
857 use tempfile::TempDir;
858
859 #[test]
860 fn mask_token_long() {
861 let masked = mask_token("ATATxxx1234abcd");
862 assert!(masked.starts_with("***"));
863 assert!(masked.ends_with("abcd"));
864 }
865
866 #[test]
867 fn mask_token_short() {
868 assert_eq!(mask_token("abc"), "***");
869 }
870
871 #[test]
872 fn mask_token_unicode_safe() {
873 let token = "token-日本語-end";
875 let result = mask_token(token);
876 assert!(result.starts_with("***"));
877 }
878
879 #[test]
880 #[cfg(not(target_os = "windows"))]
881 fn config_path_prefers_xdg_config_home() {
882 let _env = ProcessEnvLock::acquire().unwrap();
883 let dir = TempDir::new().unwrap();
884 let _config_dir = set_config_dir_env(dir.path());
885
886 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
887 }
888
889 #[test]
890 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
891 let _env = ProcessEnvLock::acquire().unwrap();
892 let dir = TempDir::new().unwrap();
893 write_config(
894 dir.path(),
895 r#"
896[default]
897host = "work.atlassian.net"
898email = "me@example.com"
899token = "secret-token"
900"#,
901 )
902 .unwrap();
903
904 let _config_dir = set_config_dir_env(dir.path());
905 let _host = EnvVarGuard::set("JIRA_HOST", " ");
906 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
907 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
908 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
909
910 let cfg = Config::load(None, None, None).unwrap();
911 assert_eq!(cfg.host, "work.atlassian.net");
912 assert_eq!(cfg.email, "me@example.com");
913 assert_eq!(cfg.token, "secret-token");
914 }
915
916 #[test]
917 fn load_accepts_documented_default_section() {
918 let _env = ProcessEnvLock::acquire().unwrap();
919 let dir = TempDir::new().unwrap();
920 write_config(
921 dir.path(),
922 r#"
923[default]
924host = "example.atlassian.net"
925email = "me@example.com"
926token = "secret-token"
927"#,
928 )
929 .unwrap();
930
931 let _config_dir = set_config_dir_env(dir.path());
932 let _host = EnvVarGuard::unset("JIRA_HOST");
933 let _email = EnvVarGuard::unset("JIRA_EMAIL");
934 let _token = EnvVarGuard::unset("JIRA_TOKEN");
935 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
936
937 let cfg = Config::load(None, None, None).unwrap();
938 assert_eq!(cfg.host, "example.atlassian.net");
939 assert_eq!(cfg.email, "me@example.com");
940 assert_eq!(cfg.token, "secret-token");
941 }
942
943 #[test]
944 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
945 let _env = ProcessEnvLock::acquire().unwrap();
946 let dir = TempDir::new().unwrap();
947 let _config_dir = set_config_dir_env(dir.path());
948 let _host = EnvVarGuard::set("JIRA_HOST", "");
949 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
950 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
951 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
952
953 let err = Config::load(None, None, None).unwrap_err();
954 assert!(matches!(err, ApiError::InvalidInput(_)));
955 assert!(err.to_string().contains("No Jira host configured"));
956 }
957
958 #[test]
959 fn permission_guidance_matches_platform() {
960 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
961
962 #[cfg(target_os = "windows")]
963 assert!(guidance.contains("AppData"));
964
965 #[cfg(not(target_os = "windows"))]
966 assert!(guidance.starts_with("chmod 600 "));
967 }
968
969 #[test]
972 fn load_env_host_overrides_file() {
973 let _env = ProcessEnvLock::acquire().unwrap();
974 let dir = TempDir::new().unwrap();
975 write_config(
976 dir.path(),
977 r#"
978[default]
979host = "file.atlassian.net"
980email = "me@example.com"
981token = "tok"
982"#,
983 )
984 .unwrap();
985
986 let _config_dir = set_config_dir_env(dir.path());
987 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
988 let _email = EnvVarGuard::unset("JIRA_EMAIL");
989 let _token = EnvVarGuard::unset("JIRA_TOKEN");
990 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
991
992 let cfg = Config::load(None, None, None).unwrap();
993 assert_eq!(cfg.host, "env.atlassian.net");
994 }
995
996 #[test]
997 fn load_cli_host_arg_overrides_env_and_file() {
998 let _env = ProcessEnvLock::acquire().unwrap();
999 let dir = TempDir::new().unwrap();
1000 write_config(
1001 dir.path(),
1002 r#"
1003[default]
1004host = "file.atlassian.net"
1005email = "me@example.com"
1006token = "tok"
1007"#,
1008 )
1009 .unwrap();
1010
1011 let _config_dir = set_config_dir_env(dir.path());
1012 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1013 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1014 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1015 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1016
1017 let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
1018 assert_eq!(cfg.host, "cli.atlassian.net");
1019 }
1020
1021 #[test]
1024 fn load_missing_token_returns_error() {
1025 let _env = ProcessEnvLock::acquire().unwrap();
1026 let dir = TempDir::new().unwrap();
1027 let _config_dir = set_config_dir_env(dir.path());
1028 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1029 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1030 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1031 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1032
1033 let err = Config::load(None, None, None).unwrap_err();
1034 assert!(matches!(err, ApiError::InvalidInput(_)));
1035 assert!(err.to_string().contains("No API token"));
1036 }
1037
1038 #[test]
1039 fn load_missing_email_for_basic_auth_returns_error() {
1040 let _env = ProcessEnvLock::acquire().unwrap();
1041 let dir = TempDir::new().unwrap();
1042 let _config_dir = set_config_dir_env(dir.path());
1043 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1044 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1045 let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
1046 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1047 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1048
1049 let err = Config::load(None, None, None).unwrap_err();
1050 assert!(matches!(err, ApiError::InvalidInput(_)));
1051 assert!(err.to_string().contains("No email configured"));
1052 }
1053
1054 #[test]
1055 fn load_invalid_toml_returns_error() {
1056 let _env = ProcessEnvLock::acquire().unwrap();
1057 let dir = TempDir::new().unwrap();
1058 write_config(dir.path(), "host = [invalid toml").unwrap();
1059
1060 let _config_dir = set_config_dir_env(dir.path());
1061 let _host = EnvVarGuard::unset("JIRA_HOST");
1062 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1063 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1064
1065 let err = Config::load(None, None, None).unwrap_err();
1066 assert!(matches!(err, ApiError::Other(_)));
1067 assert!(err.to_string().contains("parse"));
1068 }
1069
1070 #[test]
1073 fn load_pat_auth_does_not_require_email() {
1074 let _env = ProcessEnvLock::acquire().unwrap();
1075 let dir = TempDir::new().unwrap();
1076 write_config(
1077 dir.path(),
1078 r#"
1079[default]
1080host = "jira.corp.com"
1081token = "my-pat-token"
1082auth_type = "pat"
1083api_version = 2
1084"#,
1085 )
1086 .unwrap();
1087
1088 let _config_dir = set_config_dir_env(dir.path());
1089 let _host = EnvVarGuard::unset("JIRA_HOST");
1090 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1091 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1092 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1093 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1094
1095 let cfg = Config::load(None, None, None).unwrap();
1096 assert_eq!(cfg.auth_type, AuthType::Pat);
1097 assert_eq!(cfg.api_version, 2);
1098 assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
1099 }
1100
1101 #[test]
1102 fn load_jira_auth_type_env_pat_overrides_basic() {
1103 let _env = ProcessEnvLock::acquire().unwrap();
1104 let dir = TempDir::new().unwrap();
1105 write_config(
1106 dir.path(),
1107 r#"
1108[default]
1109host = "jira.corp.com"
1110email = "me@example.com"
1111token = "tok"
1112auth_type = "basic"
1113"#,
1114 )
1115 .unwrap();
1116
1117 let _config_dir = set_config_dir_env(dir.path());
1118 let _host = EnvVarGuard::unset("JIRA_HOST");
1119 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1120 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1121 let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
1122 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1123
1124 let cfg = Config::load(None, None, None).unwrap();
1125 assert_eq!(cfg.auth_type, AuthType::Pat);
1126 }
1127
1128 #[test]
1129 fn load_jira_api_version_env_overrides_default() {
1130 let _env = ProcessEnvLock::acquire().unwrap();
1131 let dir = TempDir::new().unwrap();
1132 let _config_dir = set_config_dir_env(dir.path());
1133 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1134 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1135 let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
1136 let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
1137 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1138 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1139
1140 let cfg = Config::load(None, None, None).unwrap();
1141 assert_eq!(cfg.api_version, 2);
1142 }
1143
1144 #[test]
1147 fn load_profile_arg_selects_named_section() {
1148 let _env = ProcessEnvLock::acquire().unwrap();
1149 let dir = TempDir::new().unwrap();
1150 write_config(
1151 dir.path(),
1152 r#"
1153[default]
1154host = "default.atlassian.net"
1155email = "default@example.com"
1156token = "default-tok"
1157
1158[profiles.work]
1159host = "work.atlassian.net"
1160email = "me@work.com"
1161token = "work-tok"
1162"#,
1163 )
1164 .unwrap();
1165
1166 let _config_dir = set_config_dir_env(dir.path());
1167 let _host = EnvVarGuard::unset("JIRA_HOST");
1168 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1169 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1170 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1171
1172 let cfg = Config::load(None, None, Some("work".into())).unwrap();
1173 assert_eq!(cfg.host, "work.atlassian.net");
1174 assert_eq!(cfg.email, "me@work.com");
1175 assert_eq!(cfg.token, "work-tok");
1176 }
1177
1178 #[test]
1179 fn load_jira_profile_env_selects_named_section() {
1180 let _env = ProcessEnvLock::acquire().unwrap();
1181 let dir = TempDir::new().unwrap();
1182 write_config(
1183 dir.path(),
1184 r#"
1185[default]
1186host = "default.atlassian.net"
1187email = "default@example.com"
1188token = "default-tok"
1189
1190[profiles.staging]
1191host = "staging.atlassian.net"
1192email = "me@staging.com"
1193token = "staging-tok"
1194"#,
1195 )
1196 .unwrap();
1197
1198 let _config_dir = set_config_dir_env(dir.path());
1199 let _host = EnvVarGuard::unset("JIRA_HOST");
1200 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1201 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1202 let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
1203
1204 let cfg = Config::load(None, None, None).unwrap();
1205 assert_eq!(cfg.host, "staging.atlassian.net");
1206 }
1207
1208 #[test]
1209 fn load_unknown_profile_returns_descriptive_error() {
1210 let _env = ProcessEnvLock::acquire().unwrap();
1211 let dir = TempDir::new().unwrap();
1212 write_config(
1213 dir.path(),
1214 r#"
1215[profiles.alpha]
1216host = "alpha.atlassian.net"
1217email = "me@alpha.com"
1218token = "alpha-tok"
1219"#,
1220 )
1221 .unwrap();
1222
1223 let _config_dir = set_config_dir_env(dir.path());
1224 let _host = EnvVarGuard::unset("JIRA_HOST");
1225 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1226 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1227
1228 let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
1229 assert!(matches!(err, ApiError::Other(_)));
1230 let msg = err.to_string();
1231 assert!(
1232 msg.contains("nonexistent"),
1233 "error should name the bad profile"
1234 );
1235 assert!(
1236 msg.contains("alpha"),
1237 "error should list available profiles"
1238 );
1239 }
1240
1241 #[test]
1244 fn show_json_output_includes_host_and_masked_token() {
1245 let _env = ProcessEnvLock::acquire().unwrap();
1246 let dir = TempDir::new().unwrap();
1247 write_config(
1248 dir.path(),
1249 r#"
1250[default]
1251host = "show-test.atlassian.net"
1252email = "me@example.com"
1253token = "supersecrettoken"
1254"#,
1255 )
1256 .unwrap();
1257
1258 let _config_dir = set_config_dir_env(dir.path());
1259 let _host = EnvVarGuard::unset("JIRA_HOST");
1260 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1261 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1262 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1263
1264 let out = crate::output::OutputConfig::new(true, true);
1265 show(&out, None, None, None).unwrap();
1267 }
1268
1269 #[test]
1270 fn show_text_output_renders_without_error() {
1271 let _env = ProcessEnvLock::acquire().unwrap();
1272 let dir = TempDir::new().unwrap();
1273 write_config(
1274 dir.path(),
1275 r#"
1276[default]
1277host = "show-test.atlassian.net"
1278email = "me@example.com"
1279token = "supersecrettoken"
1280"#,
1281 )
1282 .unwrap();
1283
1284 let _config_dir = set_config_dir_env(dir.path());
1285 let _host = EnvVarGuard::unset("JIRA_HOST");
1286 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1287 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1288 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1289
1290 let out = crate::output::OutputConfig::new(false, true);
1291 show(&out, None, None, None).unwrap();
1292 }
1293
1294 #[tokio::test]
1297 async fn init_json_output_includes_example_and_paths() {
1298 let out = crate::output::OutputConfig::new(true, true);
1299 init(&out, Some("jira.corp.com")).await;
1301 }
1302
1303 #[tokio::test]
1306 async fn init_non_interactive_prints_message_without_error() {
1307 let out = crate::output::OutputConfig {
1308 json: false,
1309 quiet: false,
1310 };
1311 init(&out, None).await;
1313 }
1314
1315 #[test]
1316 fn write_profile_to_config_creates_default_profile() {
1317 let dir = TempDir::new().unwrap();
1318 let path = dir.path().join("jira").join("config.toml");
1319
1320 write_profile_to_config(
1321 &path,
1322 "default",
1323 "acme.atlassian.net",
1324 Some("me@acme.com"),
1325 "secret",
1326 "basic",
1327 3,
1328 )
1329 .unwrap();
1330
1331 let content = std::fs::read_to_string(&path).unwrap();
1332 assert!(content.contains("acme.atlassian.net"));
1333 assert!(content.contains("me@acme.com"));
1334 assert!(content.contains("secret"));
1335 assert!(!content.contains("auth_type"));
1337 }
1338
1339 #[test]
1340 fn write_profile_to_config_creates_named_pat_profile() {
1341 let dir = TempDir::new().unwrap();
1342 let path = dir.path().join("config.toml");
1343
1344 write_profile_to_config(&path, "dc", "jira.corp.com", None, "pattoken", "pat", 2).unwrap();
1345
1346 let content = std::fs::read_to_string(&path).unwrap();
1347 assert!(content.contains("[profiles.dc]"));
1348 assert!(content.contains("jira.corp.com"));
1349 assert!(content.contains("pattoken"));
1350 assert!(content.contains("auth_type"));
1351 assert!(content.contains("api_version"));
1352 assert!(!content.contains("email"));
1353 }
1354
1355 #[test]
1356 fn write_profile_to_config_preserves_other_profiles() {
1357 let dir = TempDir::new().unwrap();
1358 let path = dir.path().join("config.toml");
1359
1360 std::fs::write(
1362 &path,
1363 "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
1364 )
1365 .unwrap();
1366
1367 write_profile_to_config(
1369 &path,
1370 "work",
1371 "work.atlassian.net",
1372 Some("w@work.com"),
1373 "tok2",
1374 "basic",
1375 3,
1376 )
1377 .unwrap();
1378
1379 let content = std::fs::read_to_string(&path).unwrap();
1380 assert!(
1381 content.contains("first.atlassian.net"),
1382 "default profile must be preserved"
1383 );
1384 assert!(
1385 content.contains("work.atlassian.net"),
1386 "new profile must be written"
1387 );
1388 }
1389
1390 #[test]
1393 fn remove_profile_removes_default_section() {
1394 let _env = ProcessEnvLock::acquire().unwrap();
1395 let dir = TempDir::new().unwrap();
1396 let path = write_config(
1397 dir.path(),
1398 "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
1399 )
1400 .unwrap();
1401
1402 let _config_dir = set_config_dir_env(dir.path());
1403 remove_profile("default");
1404
1405 let content = std::fs::read_to_string(&path).unwrap();
1406 assert!(!content.contains("[default]"));
1407 assert!(!content.contains("acme.atlassian.net"));
1408 }
1409
1410 #[test]
1411 fn remove_profile_removes_named_profile_preserves_others() {
1412 let _env = ProcessEnvLock::acquire().unwrap();
1413 let dir = TempDir::new().unwrap();
1414 let path = write_config(
1415 dir.path(),
1416 "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
1417 [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
1418 )
1419 .unwrap();
1420
1421 let _config_dir = set_config_dir_env(dir.path());
1422 remove_profile("work");
1423
1424 let content = std::fs::read_to_string(&path).unwrap();
1425 assert!(
1426 !content.contains("work.atlassian.net"),
1427 "work profile must be gone"
1428 );
1429 assert!(
1430 content.contains("first.atlassian.net"),
1431 "default profile must be preserved"
1432 );
1433 }
1434
1435 #[test]
1436 fn remove_profile_last_named_profile_leaves_default_intact() {
1437 let _env = ProcessEnvLock::acquire().unwrap();
1438 let dir = TempDir::new().unwrap();
1439 let path = write_config(
1440 dir.path(),
1441 "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
1442 [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
1443 )
1444 .unwrap();
1445
1446 let _config_dir = set_config_dir_env(dir.path());
1447 remove_profile("staging");
1448
1449 let content = std::fs::read_to_string(&path).unwrap();
1450 assert!(
1451 !content.contains("staging.atlassian.net"),
1452 "staging must be gone"
1453 );
1454 assert!(
1455 content.contains("acme.atlassian.net"),
1456 "default must be preserved"
1457 );
1458 }
1459
1460 #[test]
1463 fn dc_pat_url_without_host_returns_placeholder() {
1464 let url = dc_pat_url(None);
1465 assert!(url.starts_with("http://<your-host>"));
1466 assert!(url.contains(PAT_PATH));
1467 }
1468
1469 #[test]
1470 fn dc_pat_url_bare_host_adds_https_scheme() {
1471 let url = dc_pat_url(Some("jira.corp.com"));
1472 assert!(url.starts_with("https://jira.corp.com"));
1473 assert!(url.contains(PAT_PATH));
1474 }
1475
1476 #[test]
1477 fn dc_pat_url_host_with_https_scheme_is_preserved() {
1478 let url = dc_pat_url(Some("https://jira.corp.com/"));
1479 assert!(url.starts_with("https://jira.corp.com"));
1480 assert!(!url.contains("https://https://"));
1481 assert!(url.contains(PAT_PATH));
1482 }
1483
1484 #[test]
1485 fn dc_pat_url_host_with_http_scheme_is_preserved() {
1486 let url = dc_pat_url(Some("http://localhost:8080"));
1487 assert!(url.starts_with("http://localhost:8080"));
1488 assert!(url.contains(PAT_PATH));
1489 }
1490}