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 = match env_var("JIRA_AUTH_TYPE")
95 .or_else(|| normalize_value(file_profile.auth_type.clone()))
96 {
97 Some(v) => parse_auth_type(&v)?,
98 None => AuthType::default(),
99 };
100
101 let api_version = match env_var("JIRA_API_VERSION") {
102 Some(v) => parse_api_version(&v)?,
103 None => match file_profile.api_version {
104 Some(v) => validate_api_version(v)?,
105 None => 3,
106 },
107 };
108
109 let email = normalize_value(email_arg)
111 .or_else(|| env_var("JIRA_EMAIL"))
112 .or_else(|| normalize_value(file_profile.email));
113
114 let email = match auth_type {
115 AuthType::Basic => email.ok_or_else(|| {
116 ApiError::InvalidInput(
117 "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
118 )
119 })?,
120 AuthType::Pat => email.unwrap_or_default(),
121 };
122
123 let read_only = match env_var("JIRA_READ_ONLY") {
124 Some(v) => parse_read_only(&v)?,
125 None => file_profile.read_only.unwrap_or(false),
126 };
127
128 Ok(Self {
129 host,
130 email,
131 token,
132 auth_type,
133 api_version,
134 read_only,
135 })
136 }
137}
138
139fn format_available(names: &[&str]) -> String {
143 if names.is_empty() {
144 "none defined".to_string()
145 } else {
146 names.join(", ")
147 }
148}
149
150fn config_path() -> PathBuf {
151 config_dir()
152 .unwrap_or_else(|| PathBuf::from(".config"))
153 .join("jira")
154 .join("config.toml")
155}
156
157pub fn schema_config_path() -> String {
158 config_path().display().to_string()
159}
160
161pub fn schema_config_path_description() -> &'static str {
162 #[cfg(target_os = "windows")]
163 {
164 "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
165 }
166
167 #[cfg(not(target_os = "windows"))]
168 {
169 "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
170 }
171}
172
173pub fn recommended_permissions(path: &std::path::Path) -> String {
174 #[cfg(target_os = "windows")]
175 {
176 format!(
177 "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
178 path.display()
179 )
180 }
181
182 #[cfg(not(target_os = "windows"))]
183 {
184 format!("chmod 600 {}", path.display())
185 }
186}
187
188pub fn schema_recommended_permissions_example() -> &'static str {
189 #[cfg(target_os = "windows")]
190 {
191 "Keep the file in your per-user %APPDATA% directory and out of shared folders."
192 }
193
194 #[cfg(not(target_os = "windows"))]
195 {
196 "chmod 600 /path/to/config.toml"
197 }
198}
199
200pub fn schema_dc_pat_url_example() -> String {
205 dc_pat_url(None)
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
311pub fn schema_example_config() -> serde_json::Value {
318 serde_json::json!({
319 "default": {
320 "host": "mycompany.atlassian.net",
321 "email": "me@example.com",
322 "token": "your-api-token",
323 "auth_type": "basic",
324 "api_version": 3,
325 },
326 "profiles": {
327 "work": {
328 "host": "work.atlassian.net",
329 "email": "me@work.com",
330 "token": "work-token",
331 },
332 "datacenter": {
333 "host": "jira.mycompany.com",
334 "token": "your-personal-access-token",
335 "auth_type": "pat",
336 "api_version": 2,
337 }
338 }
339 })
340}
341
342fn init_json(out: &OutputConfig, host: Option<&str>) {
343 let path = config_path();
344 let path_resolution = schema_config_path_description();
345 let permission_advice = recommended_permissions(&path);
346 let example = schema_example_config();
347
348 const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
349 let pat_url = dc_pat_url(host);
350
351 out.print_data(
352 &serde_json::to_string_pretty(&serde_json::json!({
353 "configPath": path,
354 "pathResolution": path_resolution,
355 "configExists": path.exists(),
356 "tokenInstructions": CLOUD_TOKEN_URL,
357 "dcPatInstructions": pat_url,
358 "recommendedPermissions": permission_advice,
359 "example": example,
360 }))
361 .expect("failed to serialize JSON"),
362 );
363}
364
365async fn init_interactive(prefill_host: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
366 let sep = sym_dim("──────────────");
367 eprintln!("Jira CLI Setup");
368 eprintln!("{sep}");
369
370 let path = config_path();
371
372 let (target_name, existing): (Option<String>, Option<ProfileConfig>) = if path.exists() {
378 let profiles = list_profile_names(&path)?;
379
380 eprintln!();
383 eprintln!(
384 " {} {}",
385 sym_dim("Config:"),
386 sym_dim(&path.display().to_string())
387 );
388 eprintln!();
389 eprintln!(" {}:", sym_dim("Profiles"));
390 for name in &profiles {
391 let host = read_raw_profile(&path, name)
392 .ok()
393 .and_then(|p| p.host)
394 .unwrap_or_default();
395 eprintln!(" {} {} {}", sym_dim("•"), name, sym_dim(&host));
396 }
397 eprintln!();
398
399 let action = prompt("Action", "[update/add]", Some("update"))?;
400 eprintln!();
401
402 if !action.trim().eq_ignore_ascii_case("add") {
403 let default = profiles.first().map(String::as_str).unwrap_or("default");
404 let raw = if profiles.len() > 1 {
405 prompt("Profile", "", Some(default))?
406 } else {
407 default.to_owned()
408 };
409 let name = if raw.trim().is_empty() {
410 default.to_owned()
411 } else {
412 raw.trim().to_owned()
413 };
414 let cfg = read_raw_profile(&path, &name)?;
415 if profiles.len() > 1 {
416 eprintln!();
417 }
418 (Some(name), Some(cfg))
419 } else {
420 (None, None)
421 }
422 } else {
423 eprintln!();
425 (Some("default".to_owned()), None)
426 };
427
428 let is_cloud = if let Some(ref p) = existing {
430 p.auth_type.as_deref() != Some("pat")
431 } else {
432 let t = prompt("Type", sym_dim("[cloud/dc]").as_str(), Some("cloud"))?;
433 eprintln!();
434 !t.trim().eq_ignore_ascii_case("dc")
435 };
436
437 let host = if is_cloud {
439 let default_sub = existing
440 .as_ref()
441 .and_then(|p| p.host.clone())
442 .as_deref()
443 .or(prefill_host)
444 .map(|h| h.trim_end_matches(".atlassian.net").to_owned());
445 let raw = prompt_required("Subdomain", "", default_sub.as_deref())?;
446 let sub = raw.trim().trim_end_matches(".atlassian.net");
447 format!("{sub}.atlassian.net")
448 } else {
449 let default = existing
450 .as_ref()
451 .and_then(|p| p.host.clone())
452 .or_else(|| prefill_host.map(str::to_owned));
453 prompt_required("Host", "", default.as_deref())?
454 };
455
456 let (email, token, auth_type, api_version): (Option<String>, String, &str, u8) = if is_cloud {
458 const CLOUD_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
459 let default_email = existing.as_ref().and_then(|p| p.email.clone());
460 let email = prompt_required("Email", "", default_email.as_deref())?;
461 eprintln!(" {}", sym_dim(&format!("→ {CLOUD_URL}")));
462 let token_hint = if existing.as_ref().and_then(|p| p.token.as_ref()).is_some() {
463 "(Enter to keep)"
464 } else {
465 ""
466 };
467 let raw = prompt("Token", token_hint, None)?;
468 let token = if raw.trim().is_empty() {
469 existing
470 .as_ref()
471 .and_then(|p| p.token.clone())
472 .ok_or("No existing token. Please enter a token.")?
473 } else {
474 raw
475 };
476 (Some(email), token, "basic", 3)
477 } else {
478 let pat_url = dc_pat_url(Some(&host));
479 eprintln!(" {}", sym_dim(&format!("→ {pat_url}")));
480 let token_hint = if existing.as_ref().and_then(|p| p.token.as_ref()).is_some() {
481 "(Enter to keep)"
482 } else {
483 ""
484 };
485 let raw = prompt("Token", token_hint, None)?;
486 let token = if raw.trim().is_empty() {
487 existing
488 .as_ref()
489 .and_then(|p| p.token.clone())
490 .ok_or("No existing token. Please enter a token.")?
491 } else {
492 raw
493 };
494 let default_ver = existing
495 .as_ref()
496 .and_then(|p| p.api_version.map(|v| v.to_string()))
497 .unwrap_or_else(|| "2".to_owned());
498 let ver_str = prompt("API version", "", Some(&default_ver))?;
499 let api_version: u8 = ver_str.trim().parse().unwrap_or(2);
500 (None, token, "pat", api_version)
501 };
502
503 use std::io::Write;
505 eprintln!();
506 eprint!(" Verifying credentials...");
507 std::io::stderr().flush().ok();
508
509 let auth_type_enum = if auth_type == "pat" {
510 AuthType::Pat
511 } else {
512 AuthType::Basic
513 };
514
515 let verified = match crate::api::client::JiraClient::new(
516 &host,
517 email.as_deref().unwrap_or(""),
518 &token,
519 auth_type_enum,
520 api_version,
521 ) {
522 Err(e) => {
523 eprintln!(" {} {e}", sym_fail());
524 return Err(e.into());
525 }
526 Ok(client) => match client.get_myself().await {
527 Ok(myself) => {
528 eprintln!(" {} Authenticated as {}", sym_ok(), myself.display_name);
529 true
530 }
531 Err(e) => {
532 eprintln!(" {} {e}", sym_fail());
533 eprintln!();
534 let save = prompt("Save config anyway?", sym_dim("[y/N]").as_str(), Some("n"))?;
535 save.trim().eq_ignore_ascii_case("y")
536 }
537 },
538 };
539
540 if !verified {
541 eprintln!();
542 eprintln!("{sep}");
543 return Ok(());
544 }
545
546 let profile_name = match target_name {
548 Some(name) => name,
549 None => {
550 eprintln!();
551 let raw = prompt_required("Profile name", "", Some("default"))?;
552 if raw.trim().is_empty() {
553 "default".to_owned()
554 } else {
555 raw.trim().to_owned()
556 }
557 }
558 };
559
560 write_profile_to_config(
562 &path,
563 &profile_name,
564 &host,
565 email.as_deref(),
566 &token,
567 auth_type,
568 api_version,
569 )?;
570
571 #[cfg(unix)]
572 {
573 use std::os::unix::fs::PermissionsExt;
574 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
575 }
576
577 eprintln!();
578 eprintln!(" {} Config written to {}", sym_ok(), path.display());
579 eprintln!("{sep}");
580 if profile_name == "default" {
581 eprintln!(" Run: jira projects list");
582 } else {
583 eprintln!(" Run: jira --profile {profile_name} projects list");
584 }
585 eprintln!();
586
587 Ok(())
588}
589
590fn list_profile_names(path: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
592 let content = std::fs::read_to_string(path)?;
593 let doc: toml::Value = toml::from_str(&content)?;
594 let table = doc.as_table().ok_or("config is not a TOML table")?;
595
596 let mut names = Vec::new();
597 if table.contains_key("default") {
598 names.push("default".to_owned());
599 }
600 if let Some(profiles) = table.get("profiles").and_then(toml::Value::as_table) {
601 for name in profiles.keys() {
602 names.push(name.clone());
603 }
604 }
605 Ok(names)
606}
607
608fn read_raw_profile(
610 path: &std::path::Path,
611 name: &str,
612) -> Result<ProfileConfig, Box<dyn std::error::Error>> {
613 let content = std::fs::read_to_string(path)?;
614 let raw: RawConfig = toml::from_str(&content)?;
615 if name == "default" {
616 Ok(raw.default_profile())
617 } else {
618 Ok(raw.profiles.get(name).cloned().unwrap_or_default())
619 }
620}
621
622fn prompt(label: &str, hint: &str, default: Option<&str>) -> Result<String, std::io::Error> {
627 use std::io::{self, Write};
628 let hint_part = if hint.is_empty() {
629 String::new()
630 } else {
631 format!(" {hint}")
632 };
633 let default_part = match default {
634 Some(d) if !d.is_empty() => format!(" [{d}]"),
635 _ => String::new(),
636 };
637 eprint!("{} {label}{hint_part}{default_part}: ", sym_q());
638 io::stderr().flush()?;
639 let mut buf = String::new();
640 io::stdin().read_line(&mut buf)?;
641 let trimmed = buf.trim().to_owned();
642 if trimmed.is_empty() {
643 Ok(default.unwrap_or("").to_owned())
644 } else {
645 Ok(trimmed)
646 }
647}
648
649fn prompt_required(
651 label: &str,
652 hint: &str,
653 default: Option<&str>,
654) -> Result<String, std::io::Error> {
655 loop {
656 let value = prompt(label, hint, default)?;
657 if !value.trim().is_empty() {
658 return Ok(value);
659 }
660 eprintln!(" {} {label} is required.", sym_fail());
661 }
662}
663
664fn sym_q() -> String {
667 if crate::output::use_color() {
668 use owo_colors::OwoColorize;
669 "?".green().bold().to_string()
670 } else {
671 "?".to_owned()
672 }
673}
674
675fn sym_ok() -> String {
676 if crate::output::use_color() {
677 use owo_colors::OwoColorize;
678 "✔".green().to_string()
679 } else {
680 "✔".to_owned()
681 }
682}
683
684fn sym_fail() -> String {
685 if crate::output::use_color() {
686 use owo_colors::OwoColorize;
687 "✖".red().to_string()
688 } else {
689 "✖".to_owned()
690 }
691}
692
693fn sym_dim(s: &str) -> String {
694 if crate::output::use_color() {
695 use owo_colors::OwoColorize;
696 s.dimmed().to_string()
697 } else {
698 s.to_owned()
699 }
700}
701
702fn write_profile_to_config(
707 path: &std::path::Path,
708 profile_name: &str,
709 host: &str,
710 email: Option<&str>,
711 token: &str,
712 auth_type: &str,
713 api_version: u8,
714) -> Result<(), Box<dyn std::error::Error>> {
715 let existing = if path.exists() {
716 std::fs::read_to_string(path)?
717 } else {
718 String::new()
719 };
720
721 let mut doc: toml::Value = if existing.trim().is_empty() {
722 toml::Value::Table(toml::map::Map::new())
723 } else {
724 toml::from_str(&existing)?
725 };
726
727 let root = doc.as_table_mut().expect("config is a TOML table");
728
729 let mut section = toml::map::Map::new();
730 section.insert("host".to_owned(), toml::Value::String(host.to_owned()));
731 if let Some(e) = email {
732 section.insert("email".to_owned(), toml::Value::String(e.to_owned()));
733 }
734 section.insert("token".to_owned(), toml::Value::String(token.to_owned()));
735 if auth_type != "basic" {
736 section.insert(
737 "auth_type".to_owned(),
738 toml::Value::String(auth_type.to_owned()),
739 );
740 section.insert(
741 "api_version".to_owned(),
742 toml::Value::Integer(i64::from(api_version)),
743 );
744 }
745
746 if profile_name == "default" {
747 root.insert("default".to_owned(), toml::Value::Table(section));
748 } else {
749 let profiles = root
750 .entry("profiles")
751 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
752 let profiles = profiles.as_table_mut().ok_or_else(|| {
756 format!(
757 "{} defines `profiles` as something other than a table, so the `{profile_name}` profile cannot be added to it",
758 path.display()
759 )
760 })?;
761 profiles.insert(profile_name.to_owned(), toml::Value::Table(section));
762 }
763
764 if let Some(parent) = path.parent() {
765 std::fs::create_dir_all(parent)?;
766 }
767 std::fs::write(path, toml::to_string_pretty(&doc)?)?;
768
769 Ok(())
770}
771
772pub fn remove_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
778 let path = config_path();
779
780 if !path.exists() {
781 return Err(ApiError::NotFound(format!(
782 "config file at {}",
783 path.display()
784 )));
785 }
786
787 let content = std::fs::read_to_string(&path)
788 .map_err(|e| ApiError::Other(format!("Failed to read config: {e}")))?;
789 let mut doc: toml::Value = toml::from_str(&content)
790 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
791 let root = doc
792 .as_table_mut()
793 .ok_or_else(|| ApiError::Other("config is not a TOML table".to_string()))?;
794
795 let removed = if profile_name == "default" {
796 root.remove("default").is_some()
797 } else {
798 root.get_mut("profiles")
799 .and_then(toml::Value::as_table_mut)
800 .and_then(|t| t.remove(profile_name))
801 .is_some()
802 };
803
804 if !removed {
805 return Err(ApiError::NotFound(format!(
806 "profile '{profile_name}' in config. Available: {}",
807 format_available(&removable_profiles(root))
808 )));
809 }
810
811 let serialized = toml::to_string_pretty(&doc)
812 .map_err(|e| ApiError::Other(format!("Failed to serialize config: {e}")))?;
813 std::fs::write(&path, serialized)
814 .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
815
816 out.print_result(
817 &serde_json::json!({ "profile": profile_name, "removed": true }),
818 &format!("{} Removed profile '{profile_name}'", sym_ok()),
819 );
820 Ok(())
821}
822
823fn removable_profiles(root: &toml::Table) -> Vec<&str> {
826 let mut names: Vec<&str> = Vec::new();
827 if root.contains_key("default") {
828 names.push("default");
829 }
830 if let Some(profiles) = root.get("profiles").and_then(toml::Value::as_table) {
831 names.extend(profiles.keys().map(String::as_str));
832 }
833 names
834}
835
836const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
837
838fn dc_pat_url(host: Option<&str>) -> String {
843 match host {
844 Some(h) => {
845 let base = if h.starts_with("http://") || h.starts_with("https://") {
846 h.trim_end_matches('/').to_string()
847 } else {
848 format!("https://{}", h.trim_end_matches('/'))
849 };
850 format!("{base}{PAT_PATH}")
851 }
852 None => format!("http://<your-host>{PAT_PATH}"),
853 }
854}
855
856fn mask_token(token: &str) -> String {
861 let n = token.chars().count();
862 if n > 4 {
863 let suffix: String = token.chars().skip(n - 4).collect();
864 format!("***{suffix}")
865 } else {
866 "***".into()
867 }
868}
869
870fn env_var(name: &str) -> Option<String> {
871 std::env::var(name)
872 .ok()
873 .and_then(|value| normalize_value(Some(value)))
874}
875
876pub const TRUTHY: &[&str] = &["1", "true", "yes", "on"];
882pub const FALSY: &[&str] = &["0", "false", "no", "off"];
883
884pub fn is_truthy(value: &str) -> bool {
890 TRUTHY.contains(&value.trim().to_ascii_lowercase().as_str())
891}
892
893fn parse_read_only(value: &str) -> Result<bool, ApiError> {
901 let v = value.to_ascii_lowercase();
902 if TRUTHY.contains(&v.as_str()) {
903 Ok(true)
904 } else if FALSY.contains(&v.as_str()) {
905 Ok(false)
906 } else {
907 Err(ApiError::InvalidInput(format!(
908 "JIRA_READ_ONLY is set to '{value}', which is neither on ({}) nor off ({}). \
909 Refusing to run rather than guess whether writes are meant to be blocked.",
910 TRUTHY.join(", "),
911 FALSY.join(", ")
912 )))
913 }
914}
915
916fn parse_auth_type(value: &str) -> Result<AuthType, ApiError> {
921 if value.eq_ignore_ascii_case("basic") {
922 Ok(AuthType::Basic)
923 } else if value.eq_ignore_ascii_case("pat") {
924 Ok(AuthType::Pat)
925 } else {
926 Err(ApiError::InvalidInput(format!(
927 "auth_type '{value}' is not recognised. Use 'basic' (Jira Cloud) or \
928 'pat' (Jira Data Center/Server)."
929 )))
930 }
931}
932
933const API_VERSIONS: &[u8] = &[2, 3];
935
936fn parse_api_version(value: &str) -> Result<u8, ApiError> {
937 let parsed = value.parse::<u8>().map_err(|_| {
938 ApiError::InvalidInput(format!(
939 "api_version '{value}' is not a number. Use 3 (Jira Cloud) or 2 \
940 (Jira Data Center/Server)."
941 ))
942 })?;
943 validate_api_version(parsed)
944}
945
946fn validate_api_version(version: u8) -> Result<u8, ApiError> {
949 if API_VERSIONS.contains(&version) {
950 Ok(version)
951 } else {
952 Err(ApiError::InvalidInput(format!(
953 "api_version {version} is not supported. Use 3 (Jira Cloud) or 2 \
954 (Jira Data Center/Server)."
955 )))
956 }
957}
958
959fn normalize_value(value: Option<String>) -> Option<String> {
960 value.and_then(|value| {
961 let trimmed = value.trim();
962 if trimmed.is_empty() {
963 None
964 } else {
965 Some(trimmed.to_string())
966 }
967 })
968}
969
970fn normalize_str(value: Option<&str>) -> Option<&str> {
971 value.and_then(|value| {
972 let trimmed = value.trim();
973 if trimmed.is_empty() {
974 None
975 } else {
976 Some(trimmed)
977 }
978 })
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
985 use tempfile::TempDir;
986
987 #[test]
988 fn mask_token_long() {
989 let masked = mask_token("ATATxxx1234abcd");
990 assert!(masked.starts_with("***"));
991 assert!(masked.ends_with("abcd"));
992 }
993
994 #[test]
995 fn read_only_accepts_its_documented_values_in_any_case() {
996 for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "On"] {
997 assert!(parse_read_only(on).unwrap(), "{on} should enable the guard");
998 }
999 for off in ["0", "false", "FALSE", "no", "No", "off", "OFF"] {
1000 assert!(
1001 !parse_read_only(off).unwrap(),
1002 "{off} should disable the guard"
1003 );
1004 }
1005 }
1006
1007 #[test]
1010 fn read_only_refuses_a_value_it_does_not_understand() {
1011 for bad in ["enabled", "ture", "2", "y", "readonly"] {
1012 let err = parse_read_only(bad).unwrap_err();
1013 let message = err.to_string();
1014 assert!(
1015 message.contains(bad),
1016 "the rejection must quote the offending value; got: {message}"
1017 );
1018 assert!(
1019 matches!(err, ApiError::InvalidInput(_)),
1020 "{bad} must be reported as bad input, not as a Jira failure"
1021 );
1022 }
1023 }
1024
1025 #[test]
1028 fn is_truthy_accepts_any_case_and_treats_the_unknown_as_off() {
1029 for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", " on "] {
1030 assert!(is_truthy(on), "{on} should read as on");
1031 }
1032 for off in ["0", "false", "no", "off", "enabled", "ture", ""] {
1033 assert!(!is_truthy(off), "{off} should read as off");
1034 }
1035 }
1036
1037 #[test]
1040 fn load_blank_auth_type_in_the_config_file_is_treated_as_unset() {
1041 let _lock = ProcessEnvLock::acquire();
1042 let dir = TempDir::new().unwrap();
1043 write_config(
1044 dir.path(),
1045 "[default]\nhost = \"x.atlassian.net\"\nemail = \"me@example.com\"\n\
1046 token = \"t\"\nauth_type = \" \"\n",
1047 )
1048 .unwrap();
1049 let _config_dir = set_config_dir_env(dir.path());
1050 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1051 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1052
1053 let config = Config::load(None, None, None).unwrap();
1054 assert_eq!(config.auth_type, AuthType::Basic);
1055 }
1056
1057 #[test]
1058 fn auth_type_refuses_a_typo_rather_than_falling_back_to_basic() {
1059 assert_eq!(parse_auth_type("pat").unwrap(), AuthType::Pat);
1060 assert_eq!(parse_auth_type("PAT").unwrap(), AuthType::Pat);
1061 assert_eq!(parse_auth_type("basic").unwrap(), AuthType::Basic);
1062
1063 let err = parse_auth_type("ptt").unwrap_err().to_string();
1064 assert!(err.contains("ptt"), "got: {err}");
1065 assert!(
1066 err.contains("pat"),
1067 "the message must name the real spelling"
1068 );
1069 }
1070
1071 #[test]
1072 fn api_version_refuses_anything_the_client_cannot_address() {
1073 assert_eq!(parse_api_version("2").unwrap(), 2);
1074 assert_eq!(parse_api_version("3").unwrap(), 3);
1075
1076 for bad in ["v3", "", "3.0", "latest"] {
1077 assert!(
1078 parse_api_version(bad).is_err(),
1079 "{bad} is not a version number"
1080 );
1081 }
1082 let err = parse_api_version("7").unwrap_err().to_string();
1084 assert!(err.contains('7'), "got: {err}");
1085 }
1086
1087 #[test]
1088 fn mask_token_short() {
1089 assert_eq!(mask_token("abc"), "***");
1090 }
1091
1092 #[test]
1093 fn mask_token_unicode_safe() {
1094 let token = "token-日本語-end";
1096 let result = mask_token(token);
1097 assert!(result.starts_with("***"));
1098 }
1099
1100 #[test]
1101 #[cfg(not(target_os = "windows"))]
1102 fn config_path_prefers_xdg_config_home() {
1103 let _env = ProcessEnvLock::acquire().unwrap();
1104 let dir = TempDir::new().unwrap();
1105 let _config_dir = set_config_dir_env(dir.path());
1106
1107 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
1108 }
1109
1110 #[test]
1111 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
1112 let _env = ProcessEnvLock::acquire().unwrap();
1113 let dir = TempDir::new().unwrap();
1114 write_config(
1115 dir.path(),
1116 r#"
1117[default]
1118host = "work.atlassian.net"
1119email = "me@example.com"
1120token = "secret-token"
1121"#,
1122 )
1123 .unwrap();
1124
1125 let _config_dir = set_config_dir_env(dir.path());
1126 let _host = EnvVarGuard::set("JIRA_HOST", " ");
1127 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1128 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
1129 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1130
1131 let cfg = Config::load(None, None, None).unwrap();
1132 assert_eq!(cfg.host, "work.atlassian.net");
1133 assert_eq!(cfg.email, "me@example.com");
1134 assert_eq!(cfg.token, "secret-token");
1135 }
1136
1137 #[test]
1138 fn load_accepts_documented_default_section() {
1139 let _env = ProcessEnvLock::acquire().unwrap();
1140 let dir = TempDir::new().unwrap();
1141 write_config(
1142 dir.path(),
1143 r#"
1144[default]
1145host = "example.atlassian.net"
1146email = "me@example.com"
1147token = "secret-token"
1148"#,
1149 )
1150 .unwrap();
1151
1152 let _config_dir = set_config_dir_env(dir.path());
1153 let _host = EnvVarGuard::unset("JIRA_HOST");
1154 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1155 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1156 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1157
1158 let cfg = Config::load(None, None, None).unwrap();
1159 assert_eq!(cfg.host, "example.atlassian.net");
1160 assert_eq!(cfg.email, "me@example.com");
1161 assert_eq!(cfg.token, "secret-token");
1162 }
1163
1164 #[test]
1165 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
1166 let _env = ProcessEnvLock::acquire().unwrap();
1167 let dir = TempDir::new().unwrap();
1168 let _config_dir = set_config_dir_env(dir.path());
1169 let _host = EnvVarGuard::set("JIRA_HOST", "");
1170 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1171 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
1172 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1173
1174 let err = Config::load(None, None, None).unwrap_err();
1175 assert!(matches!(err, ApiError::InvalidInput(_)));
1176 assert!(err.to_string().contains("No Jira host configured"));
1177 }
1178
1179 #[test]
1180 fn permission_guidance_matches_platform() {
1181 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
1182
1183 #[cfg(target_os = "windows")]
1184 assert!(guidance.contains("AppData"));
1185
1186 #[cfg(not(target_os = "windows"))]
1187 assert!(guidance.starts_with("chmod 600 "));
1188 }
1189
1190 #[test]
1193 fn load_env_host_overrides_file() {
1194 let _env = ProcessEnvLock::acquire().unwrap();
1195 let dir = TempDir::new().unwrap();
1196 write_config(
1197 dir.path(),
1198 r#"
1199[default]
1200host = "file.atlassian.net"
1201email = "me@example.com"
1202token = "tok"
1203"#,
1204 )
1205 .unwrap();
1206
1207 let _config_dir = set_config_dir_env(dir.path());
1208 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1209 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1210 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1211 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1212
1213 let cfg = Config::load(None, None, None).unwrap();
1214 assert_eq!(cfg.host, "env.atlassian.net");
1215 }
1216
1217 #[test]
1218 fn load_cli_host_arg_overrides_env_and_file() {
1219 let _env = ProcessEnvLock::acquire().unwrap();
1220 let dir = TempDir::new().unwrap();
1221 write_config(
1222 dir.path(),
1223 r#"
1224[default]
1225host = "file.atlassian.net"
1226email = "me@example.com"
1227token = "tok"
1228"#,
1229 )
1230 .unwrap();
1231
1232 let _config_dir = set_config_dir_env(dir.path());
1233 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1234 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1235 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1236 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1237
1238 let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
1239 assert_eq!(cfg.host, "cli.atlassian.net");
1240 }
1241
1242 #[test]
1245 fn load_missing_token_returns_error() {
1246 let _env = ProcessEnvLock::acquire().unwrap();
1247 let dir = TempDir::new().unwrap();
1248 let _config_dir = set_config_dir_env(dir.path());
1249 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1250 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1251 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1252 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1253
1254 let err = Config::load(None, None, None).unwrap_err();
1255 assert!(matches!(err, ApiError::InvalidInput(_)));
1256 assert!(err.to_string().contains("No API token"));
1257 }
1258
1259 #[test]
1260 fn load_missing_email_for_basic_auth_returns_error() {
1261 let _env = ProcessEnvLock::acquire().unwrap();
1262 let dir = TempDir::new().unwrap();
1263 let _config_dir = set_config_dir_env(dir.path());
1264 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1265 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1266 let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
1267 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1268 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1269
1270 let err = Config::load(None, None, None).unwrap_err();
1271 assert!(matches!(err, ApiError::InvalidInput(_)));
1272 assert!(err.to_string().contains("No email configured"));
1273 }
1274
1275 #[test]
1276 fn load_invalid_toml_returns_error() {
1277 let _env = ProcessEnvLock::acquire().unwrap();
1278 let dir = TempDir::new().unwrap();
1279 write_config(dir.path(), "host = [invalid toml").unwrap();
1280
1281 let _config_dir = set_config_dir_env(dir.path());
1282 let _host = EnvVarGuard::unset("JIRA_HOST");
1283 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1284 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1285
1286 let err = Config::load(None, None, None).unwrap_err();
1287 assert!(matches!(err, ApiError::Other(_)));
1288 assert!(err.to_string().contains("parse"));
1289 }
1290
1291 #[test]
1294 fn load_pat_auth_does_not_require_email() {
1295 let _env = ProcessEnvLock::acquire().unwrap();
1296 let dir = TempDir::new().unwrap();
1297 write_config(
1298 dir.path(),
1299 r#"
1300[default]
1301host = "jira.corp.com"
1302token = "my-pat-token"
1303auth_type = "pat"
1304api_version = 2
1305"#,
1306 )
1307 .unwrap();
1308
1309 let _config_dir = set_config_dir_env(dir.path());
1310 let _host = EnvVarGuard::unset("JIRA_HOST");
1311 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1312 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1313 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1314 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1315
1316 let cfg = Config::load(None, None, None).unwrap();
1317 assert_eq!(cfg.auth_type, AuthType::Pat);
1318 assert_eq!(cfg.api_version, 2);
1319 assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
1320 }
1321
1322 #[test]
1323 fn load_jira_auth_type_env_pat_overrides_basic() {
1324 let _env = ProcessEnvLock::acquire().unwrap();
1325 let dir = TempDir::new().unwrap();
1326 write_config(
1327 dir.path(),
1328 r#"
1329[default]
1330host = "jira.corp.com"
1331email = "me@example.com"
1332token = "tok"
1333auth_type = "basic"
1334"#,
1335 )
1336 .unwrap();
1337
1338 let _config_dir = set_config_dir_env(dir.path());
1339 let _host = EnvVarGuard::unset("JIRA_HOST");
1340 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1341 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1342 let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
1343 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1344
1345 let cfg = Config::load(None, None, None).unwrap();
1346 assert_eq!(cfg.auth_type, AuthType::Pat);
1347 }
1348
1349 #[test]
1350 fn load_jira_api_version_env_overrides_default() {
1351 let _env = ProcessEnvLock::acquire().unwrap();
1352 let dir = TempDir::new().unwrap();
1353 let _config_dir = set_config_dir_env(dir.path());
1354 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1355 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1356 let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
1357 let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
1358 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1359 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1360
1361 let cfg = Config::load(None, None, None).unwrap();
1362 assert_eq!(cfg.api_version, 2);
1363 }
1364
1365 #[test]
1368 fn load_profile_arg_selects_named_section() {
1369 let _env = ProcessEnvLock::acquire().unwrap();
1370 let dir = TempDir::new().unwrap();
1371 write_config(
1372 dir.path(),
1373 r#"
1374[default]
1375host = "default.atlassian.net"
1376email = "default@example.com"
1377token = "default-tok"
1378
1379[profiles.work]
1380host = "work.atlassian.net"
1381email = "me@work.com"
1382token = "work-tok"
1383"#,
1384 )
1385 .unwrap();
1386
1387 let _config_dir = set_config_dir_env(dir.path());
1388 let _host = EnvVarGuard::unset("JIRA_HOST");
1389 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1390 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1391 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1392
1393 let cfg = Config::load(None, None, Some("work".into())).unwrap();
1394 assert_eq!(cfg.host, "work.atlassian.net");
1395 assert_eq!(cfg.email, "me@work.com");
1396 assert_eq!(cfg.token, "work-tok");
1397 }
1398
1399 #[test]
1400 fn load_jira_profile_env_selects_named_section() {
1401 let _env = ProcessEnvLock::acquire().unwrap();
1402 let dir = TempDir::new().unwrap();
1403 write_config(
1404 dir.path(),
1405 r#"
1406[default]
1407host = "default.atlassian.net"
1408email = "default@example.com"
1409token = "default-tok"
1410
1411[profiles.staging]
1412host = "staging.atlassian.net"
1413email = "me@staging.com"
1414token = "staging-tok"
1415"#,
1416 )
1417 .unwrap();
1418
1419 let _config_dir = set_config_dir_env(dir.path());
1420 let _host = EnvVarGuard::unset("JIRA_HOST");
1421 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1422 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1423 let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
1424
1425 let cfg = Config::load(None, None, None).unwrap();
1426 assert_eq!(cfg.host, "staging.atlassian.net");
1427 }
1428
1429 #[test]
1430 fn load_unknown_profile_returns_descriptive_error() {
1431 let _env = ProcessEnvLock::acquire().unwrap();
1432 let dir = TempDir::new().unwrap();
1433 write_config(
1434 dir.path(),
1435 r#"
1436[profiles.alpha]
1437host = "alpha.atlassian.net"
1438email = "me@alpha.com"
1439token = "alpha-tok"
1440"#,
1441 )
1442 .unwrap();
1443
1444 let _config_dir = set_config_dir_env(dir.path());
1445 let _host = EnvVarGuard::unset("JIRA_HOST");
1446 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1447 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1448
1449 let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
1450 assert!(
1451 matches!(err, ApiError::NotFound(_)),
1452 "selecting a profile that is not in the config is a not_found condition, \
1453 not an unexpected error: {err:?}"
1454 );
1455 let msg = err.to_string();
1456 assert!(
1457 msg.contains("nonexistent"),
1458 "error should name the bad profile"
1459 );
1460 assert!(
1461 msg.contains("alpha"),
1462 "error should list available profiles"
1463 );
1464 }
1465
1466 #[test]
1469 fn show_json_output_includes_host_and_masked_token() {
1470 let _env = ProcessEnvLock::acquire().unwrap();
1471 let dir = TempDir::new().unwrap();
1472 write_config(
1473 dir.path(),
1474 r#"
1475[default]
1476host = "show-test.atlassian.net"
1477email = "me@example.com"
1478token = "supersecrettoken"
1479"#,
1480 )
1481 .unwrap();
1482
1483 let _config_dir = set_config_dir_env(dir.path());
1484 let _host = EnvVarGuard::unset("JIRA_HOST");
1485 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1486 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1487 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1488
1489 let out = crate::output::OutputConfig::new(true, false, true);
1490 show(&out, None, None, None).unwrap();
1492 }
1493
1494 #[test]
1495 fn show_text_output_renders_without_error() {
1496 let _env = ProcessEnvLock::acquire().unwrap();
1497 let dir = TempDir::new().unwrap();
1498 write_config(
1499 dir.path(),
1500 r#"
1501[default]
1502host = "show-test.atlassian.net"
1503email = "me@example.com"
1504token = "supersecrettoken"
1505"#,
1506 )
1507 .unwrap();
1508
1509 let _config_dir = set_config_dir_env(dir.path());
1510 let _host = EnvVarGuard::unset("JIRA_HOST");
1511 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1512 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1513 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1514
1515 let out = crate::output::OutputConfig::new(false, false, true);
1516 show(&out, None, None, None).unwrap();
1517 }
1518
1519 #[tokio::test]
1522 async fn init_json_output_includes_example_and_paths() {
1523 let out = crate::output::OutputConfig::new(true, false, true);
1524 init(&out, Some("jira.corp.com")).await;
1526 }
1527
1528 #[tokio::test]
1531 async fn init_non_interactive_prints_message_without_error() {
1532 let out = crate::output::OutputConfig {
1533 json: false,
1534 quiet: false,
1535 };
1536 init(&out, None).await;
1538 }
1539
1540 #[test]
1541 fn write_profile_to_config_creates_default_profile() {
1542 let dir = TempDir::new().unwrap();
1543 let path = dir.path().join("jira").join("config.toml");
1544
1545 write_profile_to_config(
1546 &path,
1547 "default",
1548 "acme.atlassian.net",
1549 Some("me@acme.com"),
1550 "secret",
1551 "basic",
1552 3,
1553 )
1554 .unwrap();
1555
1556 let content = std::fs::read_to_string(&path).unwrap();
1557 assert!(content.contains("acme.atlassian.net"));
1558 assert!(content.contains("me@acme.com"));
1559 assert!(content.contains("secret"));
1560 assert!(!content.contains("auth_type"));
1562 }
1563
1564 #[test]
1565 fn write_profile_to_config_creates_named_pat_profile() {
1566 let dir = TempDir::new().unwrap();
1567 let path = dir.path().join("config.toml");
1568
1569 write_profile_to_config(&path, "dc", "jira.corp.com", None, "pattoken", "pat", 2).unwrap();
1570
1571 let content = std::fs::read_to_string(&path).unwrap();
1572 assert!(content.contains("[profiles.dc]"));
1573 assert!(content.contains("jira.corp.com"));
1574 assert!(content.contains("pattoken"));
1575 assert!(content.contains("auth_type"));
1576 assert!(content.contains("api_version"));
1577 assert!(!content.contains("email"));
1578 }
1579
1580 #[test]
1581 fn a_profiles_key_that_is_not_a_table_is_reported_rather_than_panicked_on() {
1582 let dir = TempDir::new().unwrap();
1583 let path = dir.path().join("config.toml");
1584 std::fs::write(&path, "profiles = 5\n").unwrap();
1585
1586 let err =
1587 write_profile_to_config(&path, "work", "h.atlassian.net", None, "tok", "basic", 3)
1588 .expect_err("a `profiles` integer cannot hold a profile")
1589 .to_string();
1590 assert!(
1591 err.contains("profiles") && err.contains("work"),
1592 "the message must name the key and the profile being added: {err}"
1593 );
1594 assert!(
1595 err.contains(&path.display().to_string()),
1596 "the message must name the file to edit: {err}"
1597 );
1598
1599 let good = dir.path().join("good.toml");
1602 std::fs::write(&good, "[profiles.other]\nhost = \"a.b\"\n").unwrap();
1603 write_profile_to_config(&good, "work", "h.atlassian.net", None, "tok", "basic", 3).unwrap();
1604 let written = std::fs::read_to_string(&good).unwrap();
1605 assert!(written.contains("[profiles.work]") && written.contains("[profiles.other]"));
1606 }
1607
1608 #[test]
1609 fn write_profile_to_config_preserves_other_profiles() {
1610 let dir = TempDir::new().unwrap();
1611 let path = dir.path().join("config.toml");
1612
1613 std::fs::write(
1615 &path,
1616 "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
1617 )
1618 .unwrap();
1619
1620 write_profile_to_config(
1622 &path,
1623 "work",
1624 "work.atlassian.net",
1625 Some("w@work.com"),
1626 "tok2",
1627 "basic",
1628 3,
1629 )
1630 .unwrap();
1631
1632 let content = std::fs::read_to_string(&path).unwrap();
1633 assert!(
1634 content.contains("first.atlassian.net"),
1635 "default profile must be preserved"
1636 );
1637 assert!(
1638 content.contains("work.atlassian.net"),
1639 "new profile must be written"
1640 );
1641 }
1642
1643 #[test]
1646 fn remove_profile_removes_default_section() {
1647 let _env = ProcessEnvLock::acquire().unwrap();
1648 let dir = TempDir::new().unwrap();
1649 let path = write_config(
1650 dir.path(),
1651 "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
1652 )
1653 .unwrap();
1654
1655 let _config_dir = set_config_dir_env(dir.path());
1656 remove_profile(&OutputConfig::new(true, false, true), "default").unwrap();
1657
1658 let content = std::fs::read_to_string(&path).unwrap();
1659 assert!(!content.contains("[default]"));
1660 assert!(!content.contains("acme.atlassian.net"));
1661 }
1662
1663 #[test]
1664 fn remove_profile_removes_named_profile_preserves_others() {
1665 let _env = ProcessEnvLock::acquire().unwrap();
1666 let dir = TempDir::new().unwrap();
1667 let path = write_config(
1668 dir.path(),
1669 "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
1670 [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
1671 )
1672 .unwrap();
1673
1674 let _config_dir = set_config_dir_env(dir.path());
1675 remove_profile(&OutputConfig::new(true, false, true), "work").unwrap();
1676
1677 let content = std::fs::read_to_string(&path).unwrap();
1678 assert!(
1679 !content.contains("work.atlassian.net"),
1680 "work profile must be gone"
1681 );
1682 assert!(
1683 content.contains("first.atlassian.net"),
1684 "default profile must be preserved"
1685 );
1686 }
1687
1688 #[test]
1689 fn remove_profile_last_named_profile_leaves_default_intact() {
1690 let _env = ProcessEnvLock::acquire().unwrap();
1691 let dir = TempDir::new().unwrap();
1692 let path = write_config(
1693 dir.path(),
1694 "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
1695 [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
1696 )
1697 .unwrap();
1698
1699 let _config_dir = set_config_dir_env(dir.path());
1700 remove_profile(&OutputConfig::new(true, false, true), "staging").unwrap();
1701
1702 let content = std::fs::read_to_string(&path).unwrap();
1703 assert!(
1704 !content.contains("staging.atlassian.net"),
1705 "staging must be gone"
1706 );
1707 assert!(
1708 content.contains("acme.atlassian.net"),
1709 "default must be preserved"
1710 );
1711 }
1712
1713 #[test]
1716 fn dc_pat_url_without_host_returns_placeholder() {
1717 let url = dc_pat_url(None);
1718 assert!(url.starts_with("http://<your-host>"));
1719 assert!(url.contains(PAT_PATH));
1720 }
1721
1722 #[test]
1723 fn dc_pat_url_bare_host_adds_https_scheme() {
1724 let url = dc_pat_url(Some("jira.corp.com"));
1725 assert!(url.starts_with("https://jira.corp.com"));
1726 assert!(url.contains(PAT_PATH));
1727 }
1728
1729 #[test]
1730 fn dc_pat_url_host_with_https_scheme_is_preserved() {
1731 let url = dc_pat_url(Some("https://jira.corp.com/"));
1732 assert!(url.starts_with("https://jira.corp.com"));
1733 assert!(!url.contains("https://https://"));
1734 assert!(url.contains(PAT_PATH));
1735 }
1736
1737 #[test]
1738 fn dc_pat_url_host_with_http_scheme_is_preserved() {
1739 let url = dc_pat_url(Some("http://localhost:8080"));
1740 assert!(url.starts_with("http://localhost:8080"));
1741 assert!(url.contains(PAT_PATH));
1742 }
1743}