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