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