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 profiles
753 .as_table_mut()
754 .expect("profiles is a TOML table")
755 .insert(profile_name.to_owned(), toml::Value::Table(section));
756 }
757
758 if let Some(parent) = path.parent() {
759 std::fs::create_dir_all(parent)?;
760 }
761 std::fs::write(path, toml::to_string_pretty(&doc)?)?;
762
763 Ok(())
764}
765
766pub fn remove_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
772 let path = config_path();
773
774 if !path.exists() {
775 return Err(ApiError::NotFound(format!(
776 "config file at {}",
777 path.display()
778 )));
779 }
780
781 let content = std::fs::read_to_string(&path)
782 .map_err(|e| ApiError::Other(format!("Failed to read config: {e}")))?;
783 let mut doc: toml::Value = toml::from_str(&content)
784 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
785 let root = doc
786 .as_table_mut()
787 .ok_or_else(|| ApiError::Other("config is not a TOML table".to_string()))?;
788
789 let removed = if profile_name == "default" {
790 root.remove("default").is_some()
791 } else {
792 root.get_mut("profiles")
793 .and_then(toml::Value::as_table_mut)
794 .and_then(|t| t.remove(profile_name))
795 .is_some()
796 };
797
798 if !removed {
799 return Err(ApiError::NotFound(format!(
800 "profile '{profile_name}' in config. Available: {}",
801 format_available(&removable_profiles(root))
802 )));
803 }
804
805 let serialized = toml::to_string_pretty(&doc)
806 .map_err(|e| ApiError::Other(format!("Failed to serialize config: {e}")))?;
807 std::fs::write(&path, serialized)
808 .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
809
810 out.print_result(
811 &serde_json::json!({ "profile": profile_name, "removed": true }),
812 &format!("{} Removed profile '{profile_name}'", sym_ok()),
813 );
814 Ok(())
815}
816
817fn removable_profiles(root: &toml::Table) -> Vec<&str> {
820 let mut names: Vec<&str> = Vec::new();
821 if root.contains_key("default") {
822 names.push("default");
823 }
824 if let Some(profiles) = root.get("profiles").and_then(toml::Value::as_table) {
825 names.extend(profiles.keys().map(String::as_str));
826 }
827 names
828}
829
830const PAT_PATH: &str = "/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens";
831
832fn dc_pat_url(host: Option<&str>) -> String {
837 match host {
838 Some(h) => {
839 let base = if h.starts_with("http://") || h.starts_with("https://") {
840 h.trim_end_matches('/').to_string()
841 } else {
842 format!("https://{}", h.trim_end_matches('/'))
843 };
844 format!("{base}{PAT_PATH}")
845 }
846 None => format!("http://<your-host>{PAT_PATH}"),
847 }
848}
849
850fn mask_token(token: &str) -> String {
855 let n = token.chars().count();
856 if n > 4 {
857 let suffix: String = token.chars().skip(n - 4).collect();
858 format!("***{suffix}")
859 } else {
860 "***".into()
861 }
862}
863
864fn env_var(name: &str) -> Option<String> {
865 std::env::var(name)
866 .ok()
867 .and_then(|value| normalize_value(Some(value)))
868}
869
870pub const TRUTHY: &[&str] = &["1", "true", "yes", "on"];
876pub const FALSY: &[&str] = &["0", "false", "no", "off"];
877
878pub fn is_truthy(value: &str) -> bool {
884 TRUTHY.contains(&value.trim().to_ascii_lowercase().as_str())
885}
886
887fn parse_read_only(value: &str) -> Result<bool, ApiError> {
895 let v = value.to_ascii_lowercase();
896 if TRUTHY.contains(&v.as_str()) {
897 Ok(true)
898 } else if FALSY.contains(&v.as_str()) {
899 Ok(false)
900 } else {
901 Err(ApiError::InvalidInput(format!(
902 "JIRA_READ_ONLY is set to '{value}', which is neither on ({}) nor off ({}). \
903 Refusing to run rather than guess whether writes are meant to be blocked.",
904 TRUTHY.join(", "),
905 FALSY.join(", ")
906 )))
907 }
908}
909
910fn parse_auth_type(value: &str) -> Result<AuthType, ApiError> {
915 if value.eq_ignore_ascii_case("basic") {
916 Ok(AuthType::Basic)
917 } else if value.eq_ignore_ascii_case("pat") {
918 Ok(AuthType::Pat)
919 } else {
920 Err(ApiError::InvalidInput(format!(
921 "auth_type '{value}' is not recognised. Use 'basic' (Jira Cloud) or \
922 'pat' (Jira Data Center/Server)."
923 )))
924 }
925}
926
927const API_VERSIONS: &[u8] = &[2, 3];
929
930fn parse_api_version(value: &str) -> Result<u8, ApiError> {
931 let parsed = value.parse::<u8>().map_err(|_| {
932 ApiError::InvalidInput(format!(
933 "api_version '{value}' is not a number. Use 3 (Jira Cloud) or 2 \
934 (Jira Data Center/Server)."
935 ))
936 })?;
937 validate_api_version(parsed)
938}
939
940fn validate_api_version(version: u8) -> Result<u8, ApiError> {
943 if API_VERSIONS.contains(&version) {
944 Ok(version)
945 } else {
946 Err(ApiError::InvalidInput(format!(
947 "api_version {version} is not supported. Use 3 (Jira Cloud) or 2 \
948 (Jira Data Center/Server)."
949 )))
950 }
951}
952
953fn normalize_value(value: Option<String>) -> Option<String> {
954 value.and_then(|value| {
955 let trimmed = value.trim();
956 if trimmed.is_empty() {
957 None
958 } else {
959 Some(trimmed.to_string())
960 }
961 })
962}
963
964fn normalize_str(value: Option<&str>) -> Option<&str> {
965 value.and_then(|value| {
966 let trimmed = value.trim();
967 if trimmed.is_empty() {
968 None
969 } else {
970 Some(trimmed)
971 }
972 })
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
979 use tempfile::TempDir;
980
981 #[test]
982 fn mask_token_long() {
983 let masked = mask_token("ATATxxx1234abcd");
984 assert!(masked.starts_with("***"));
985 assert!(masked.ends_with("abcd"));
986 }
987
988 #[test]
989 fn read_only_accepts_its_documented_values_in_any_case() {
990 for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "On"] {
991 assert!(parse_read_only(on).unwrap(), "{on} should enable the guard");
992 }
993 for off in ["0", "false", "FALSE", "no", "No", "off", "OFF"] {
994 assert!(
995 !parse_read_only(off).unwrap(),
996 "{off} should disable the guard"
997 );
998 }
999 }
1000
1001 #[test]
1004 fn read_only_refuses_a_value_it_does_not_understand() {
1005 for bad in ["enabled", "ture", "2", "y", "readonly"] {
1006 let err = parse_read_only(bad).unwrap_err();
1007 let message = err.to_string();
1008 assert!(
1009 message.contains(bad),
1010 "the rejection must quote the offending value; got: {message}"
1011 );
1012 assert!(
1013 matches!(err, ApiError::InvalidInput(_)),
1014 "{bad} must be reported as bad input, not as a Jira failure"
1015 );
1016 }
1017 }
1018
1019 #[test]
1022 fn is_truthy_accepts_any_case_and_treats_the_unknown_as_off() {
1023 for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", " on "] {
1024 assert!(is_truthy(on), "{on} should read as on");
1025 }
1026 for off in ["0", "false", "no", "off", "enabled", "ture", ""] {
1027 assert!(!is_truthy(off), "{off} should read as off");
1028 }
1029 }
1030
1031 #[test]
1034 fn load_blank_auth_type_in_the_config_file_is_treated_as_unset() {
1035 let _lock = ProcessEnvLock::acquire();
1036 let dir = TempDir::new().unwrap();
1037 write_config(
1038 dir.path(),
1039 "[default]\nhost = \"x.atlassian.net\"\nemail = \"me@example.com\"\n\
1040 token = \"t\"\nauth_type = \" \"\n",
1041 )
1042 .unwrap();
1043 let _config_dir = set_config_dir_env(dir.path());
1044 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1045 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1046
1047 let config = Config::load(None, None, None).unwrap();
1048 assert_eq!(config.auth_type, AuthType::Basic);
1049 }
1050
1051 #[test]
1052 fn auth_type_refuses_a_typo_rather_than_falling_back_to_basic() {
1053 assert_eq!(parse_auth_type("pat").unwrap(), AuthType::Pat);
1054 assert_eq!(parse_auth_type("PAT").unwrap(), AuthType::Pat);
1055 assert_eq!(parse_auth_type("basic").unwrap(), AuthType::Basic);
1056
1057 let err = parse_auth_type("ptt").unwrap_err().to_string();
1058 assert!(err.contains("ptt"), "got: {err}");
1059 assert!(
1060 err.contains("pat"),
1061 "the message must name the real spelling"
1062 );
1063 }
1064
1065 #[test]
1066 fn api_version_refuses_anything_the_client_cannot_address() {
1067 assert_eq!(parse_api_version("2").unwrap(), 2);
1068 assert_eq!(parse_api_version("3").unwrap(), 3);
1069
1070 for bad in ["v3", "", "3.0", "latest"] {
1071 assert!(
1072 parse_api_version(bad).is_err(),
1073 "{bad} is not a version number"
1074 );
1075 }
1076 let err = parse_api_version("7").unwrap_err().to_string();
1078 assert!(err.contains('7'), "got: {err}");
1079 }
1080
1081 #[test]
1082 fn mask_token_short() {
1083 assert_eq!(mask_token("abc"), "***");
1084 }
1085
1086 #[test]
1087 fn mask_token_unicode_safe() {
1088 let token = "token-日本語-end";
1090 let result = mask_token(token);
1091 assert!(result.starts_with("***"));
1092 }
1093
1094 #[test]
1095 #[cfg(not(target_os = "windows"))]
1096 fn config_path_prefers_xdg_config_home() {
1097 let _env = ProcessEnvLock::acquire().unwrap();
1098 let dir = TempDir::new().unwrap();
1099 let _config_dir = set_config_dir_env(dir.path());
1100
1101 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
1102 }
1103
1104 #[test]
1105 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
1106 let _env = ProcessEnvLock::acquire().unwrap();
1107 let dir = TempDir::new().unwrap();
1108 write_config(
1109 dir.path(),
1110 r#"
1111[default]
1112host = "work.atlassian.net"
1113email = "me@example.com"
1114token = "secret-token"
1115"#,
1116 )
1117 .unwrap();
1118
1119 let _config_dir = set_config_dir_env(dir.path());
1120 let _host = EnvVarGuard::set("JIRA_HOST", " ");
1121 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1122 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
1123 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1124
1125 let cfg = Config::load(None, None, None).unwrap();
1126 assert_eq!(cfg.host, "work.atlassian.net");
1127 assert_eq!(cfg.email, "me@example.com");
1128 assert_eq!(cfg.token, "secret-token");
1129 }
1130
1131 #[test]
1132 fn load_accepts_documented_default_section() {
1133 let _env = ProcessEnvLock::acquire().unwrap();
1134 let dir = TempDir::new().unwrap();
1135 write_config(
1136 dir.path(),
1137 r#"
1138[default]
1139host = "example.atlassian.net"
1140email = "me@example.com"
1141token = "secret-token"
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 _profile = EnvVarGuard::unset("JIRA_PROFILE");
1151
1152 let cfg = Config::load(None, None, None).unwrap();
1153 assert_eq!(cfg.host, "example.atlassian.net");
1154 assert_eq!(cfg.email, "me@example.com");
1155 assert_eq!(cfg.token, "secret-token");
1156 }
1157
1158 #[test]
1159 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
1160 let _env = ProcessEnvLock::acquire().unwrap();
1161 let dir = TempDir::new().unwrap();
1162 let _config_dir = set_config_dir_env(dir.path());
1163 let _host = EnvVarGuard::set("JIRA_HOST", "");
1164 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1165 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
1166 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1167
1168 let err = Config::load(None, None, None).unwrap_err();
1169 assert!(matches!(err, ApiError::InvalidInput(_)));
1170 assert!(err.to_string().contains("No Jira host configured"));
1171 }
1172
1173 #[test]
1174 fn permission_guidance_matches_platform() {
1175 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
1176
1177 #[cfg(target_os = "windows")]
1178 assert!(guidance.contains("AppData"));
1179
1180 #[cfg(not(target_os = "windows"))]
1181 assert!(guidance.starts_with("chmod 600 "));
1182 }
1183
1184 #[test]
1187 fn load_env_host_overrides_file() {
1188 let _env = ProcessEnvLock::acquire().unwrap();
1189 let dir = TempDir::new().unwrap();
1190 write_config(
1191 dir.path(),
1192 r#"
1193[default]
1194host = "file.atlassian.net"
1195email = "me@example.com"
1196token = "tok"
1197"#,
1198 )
1199 .unwrap();
1200
1201 let _config_dir = set_config_dir_env(dir.path());
1202 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1203 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1204 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1205 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1206
1207 let cfg = Config::load(None, None, None).unwrap();
1208 assert_eq!(cfg.host, "env.atlassian.net");
1209 }
1210
1211 #[test]
1212 fn load_cli_host_arg_overrides_env_and_file() {
1213 let _env = ProcessEnvLock::acquire().unwrap();
1214 let dir = TempDir::new().unwrap();
1215 write_config(
1216 dir.path(),
1217 r#"
1218[default]
1219host = "file.atlassian.net"
1220email = "me@example.com"
1221token = "tok"
1222"#,
1223 )
1224 .unwrap();
1225
1226 let _config_dir = set_config_dir_env(dir.path());
1227 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
1228 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1229 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1230 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1231
1232 let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
1233 assert_eq!(cfg.host, "cli.atlassian.net");
1234 }
1235
1236 #[test]
1239 fn load_missing_token_returns_error() {
1240 let _env = ProcessEnvLock::acquire().unwrap();
1241 let dir = TempDir::new().unwrap();
1242 let _config_dir = set_config_dir_env(dir.path());
1243 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1244 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1245 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1246 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1247
1248 let err = Config::load(None, None, None).unwrap_err();
1249 assert!(matches!(err, ApiError::InvalidInput(_)));
1250 assert!(err.to_string().contains("No API token"));
1251 }
1252
1253 #[test]
1254 fn load_missing_email_for_basic_auth_returns_error() {
1255 let _env = ProcessEnvLock::acquire().unwrap();
1256 let dir = TempDir::new().unwrap();
1257 let _config_dir = set_config_dir_env(dir.path());
1258 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1259 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1260 let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
1261 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1262 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1263
1264 let err = Config::load(None, None, None).unwrap_err();
1265 assert!(matches!(err, ApiError::InvalidInput(_)));
1266 assert!(err.to_string().contains("No email configured"));
1267 }
1268
1269 #[test]
1270 fn load_invalid_toml_returns_error() {
1271 let _env = ProcessEnvLock::acquire().unwrap();
1272 let dir = TempDir::new().unwrap();
1273 write_config(dir.path(), "host = [invalid toml").unwrap();
1274
1275 let _config_dir = set_config_dir_env(dir.path());
1276 let _host = EnvVarGuard::unset("JIRA_HOST");
1277 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1278 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1279
1280 let err = Config::load(None, None, None).unwrap_err();
1281 assert!(matches!(err, ApiError::Other(_)));
1282 assert!(err.to_string().contains("parse"));
1283 }
1284
1285 #[test]
1288 fn load_pat_auth_does_not_require_email() {
1289 let _env = ProcessEnvLock::acquire().unwrap();
1290 let dir = TempDir::new().unwrap();
1291 write_config(
1292 dir.path(),
1293 r#"
1294[default]
1295host = "jira.corp.com"
1296token = "my-pat-token"
1297auth_type = "pat"
1298api_version = 2
1299"#,
1300 )
1301 .unwrap();
1302
1303 let _config_dir = set_config_dir_env(dir.path());
1304 let _host = EnvVarGuard::unset("JIRA_HOST");
1305 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1306 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1307 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1308 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1309
1310 let cfg = Config::load(None, None, None).unwrap();
1311 assert_eq!(cfg.auth_type, AuthType::Pat);
1312 assert_eq!(cfg.api_version, 2);
1313 assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
1314 }
1315
1316 #[test]
1317 fn load_jira_auth_type_env_pat_overrides_basic() {
1318 let _env = ProcessEnvLock::acquire().unwrap();
1319 let dir = TempDir::new().unwrap();
1320 write_config(
1321 dir.path(),
1322 r#"
1323[default]
1324host = "jira.corp.com"
1325email = "me@example.com"
1326token = "tok"
1327auth_type = "basic"
1328"#,
1329 )
1330 .unwrap();
1331
1332 let _config_dir = set_config_dir_env(dir.path());
1333 let _host = EnvVarGuard::unset("JIRA_HOST");
1334 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1335 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1336 let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
1337 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1338
1339 let cfg = Config::load(None, None, None).unwrap();
1340 assert_eq!(cfg.auth_type, AuthType::Pat);
1341 }
1342
1343 #[test]
1344 fn load_jira_api_version_env_overrides_default() {
1345 let _env = ProcessEnvLock::acquire().unwrap();
1346 let dir = TempDir::new().unwrap();
1347 let _config_dir = set_config_dir_env(dir.path());
1348 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
1349 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
1350 let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
1351 let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
1352 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1353 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1354
1355 let cfg = Config::load(None, None, None).unwrap();
1356 assert_eq!(cfg.api_version, 2);
1357 }
1358
1359 #[test]
1362 fn load_profile_arg_selects_named_section() {
1363 let _env = ProcessEnvLock::acquire().unwrap();
1364 let dir = TempDir::new().unwrap();
1365 write_config(
1366 dir.path(),
1367 r#"
1368[default]
1369host = "default.atlassian.net"
1370email = "default@example.com"
1371token = "default-tok"
1372
1373[profiles.work]
1374host = "work.atlassian.net"
1375email = "me@work.com"
1376token = "work-tok"
1377"#,
1378 )
1379 .unwrap();
1380
1381 let _config_dir = set_config_dir_env(dir.path());
1382 let _host = EnvVarGuard::unset("JIRA_HOST");
1383 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1384 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1385 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1386
1387 let cfg = Config::load(None, None, Some("work".into())).unwrap();
1388 assert_eq!(cfg.host, "work.atlassian.net");
1389 assert_eq!(cfg.email, "me@work.com");
1390 assert_eq!(cfg.token, "work-tok");
1391 }
1392
1393 #[test]
1394 fn load_jira_profile_env_selects_named_section() {
1395 let _env = ProcessEnvLock::acquire().unwrap();
1396 let dir = TempDir::new().unwrap();
1397 write_config(
1398 dir.path(),
1399 r#"
1400[default]
1401host = "default.atlassian.net"
1402email = "default@example.com"
1403token = "default-tok"
1404
1405[profiles.staging]
1406host = "staging.atlassian.net"
1407email = "me@staging.com"
1408token = "staging-tok"
1409"#,
1410 )
1411 .unwrap();
1412
1413 let _config_dir = set_config_dir_env(dir.path());
1414 let _host = EnvVarGuard::unset("JIRA_HOST");
1415 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1416 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1417 let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
1418
1419 let cfg = Config::load(None, None, None).unwrap();
1420 assert_eq!(cfg.host, "staging.atlassian.net");
1421 }
1422
1423 #[test]
1424 fn load_unknown_profile_returns_descriptive_error() {
1425 let _env = ProcessEnvLock::acquire().unwrap();
1426 let dir = TempDir::new().unwrap();
1427 write_config(
1428 dir.path(),
1429 r#"
1430[profiles.alpha]
1431host = "alpha.atlassian.net"
1432email = "me@alpha.com"
1433token = "alpha-tok"
1434"#,
1435 )
1436 .unwrap();
1437
1438 let _config_dir = set_config_dir_env(dir.path());
1439 let _host = EnvVarGuard::unset("JIRA_HOST");
1440 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1441 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1442
1443 let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
1444 assert!(
1445 matches!(err, ApiError::NotFound(_)),
1446 "selecting a profile that is not in the config is a not_found condition, \
1447 not an unexpected error: {err:?}"
1448 );
1449 let msg = err.to_string();
1450 assert!(
1451 msg.contains("nonexistent"),
1452 "error should name the bad profile"
1453 );
1454 assert!(
1455 msg.contains("alpha"),
1456 "error should list available profiles"
1457 );
1458 }
1459
1460 #[test]
1463 fn show_json_output_includes_host_and_masked_token() {
1464 let _env = ProcessEnvLock::acquire().unwrap();
1465 let dir = TempDir::new().unwrap();
1466 write_config(
1467 dir.path(),
1468 r#"
1469[default]
1470host = "show-test.atlassian.net"
1471email = "me@example.com"
1472token = "supersecrettoken"
1473"#,
1474 )
1475 .unwrap();
1476
1477 let _config_dir = set_config_dir_env(dir.path());
1478 let _host = EnvVarGuard::unset("JIRA_HOST");
1479 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1480 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1481 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1482
1483 let out = crate::output::OutputConfig::new(true, false, true);
1484 show(&out, None, None, None).unwrap();
1486 }
1487
1488 #[test]
1489 fn show_text_output_renders_without_error() {
1490 let _env = ProcessEnvLock::acquire().unwrap();
1491 let dir = TempDir::new().unwrap();
1492 write_config(
1493 dir.path(),
1494 r#"
1495[default]
1496host = "show-test.atlassian.net"
1497email = "me@example.com"
1498token = "supersecrettoken"
1499"#,
1500 )
1501 .unwrap();
1502
1503 let _config_dir = set_config_dir_env(dir.path());
1504 let _host = EnvVarGuard::unset("JIRA_HOST");
1505 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1506 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1507 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1508
1509 let out = crate::output::OutputConfig::new(false, false, true);
1510 show(&out, None, None, None).unwrap();
1511 }
1512
1513 #[tokio::test]
1516 async fn init_json_output_includes_example_and_paths() {
1517 let out = crate::output::OutputConfig::new(true, false, true);
1518 init(&out, Some("jira.corp.com")).await;
1520 }
1521
1522 #[tokio::test]
1525 async fn init_non_interactive_prints_message_without_error() {
1526 let out = crate::output::OutputConfig {
1527 json: false,
1528 quiet: false,
1529 };
1530 init(&out, None).await;
1532 }
1533
1534 #[test]
1535 fn write_profile_to_config_creates_default_profile() {
1536 let dir = TempDir::new().unwrap();
1537 let path = dir.path().join("jira").join("config.toml");
1538
1539 write_profile_to_config(
1540 &path,
1541 "default",
1542 "acme.atlassian.net",
1543 Some("me@acme.com"),
1544 "secret",
1545 "basic",
1546 3,
1547 )
1548 .unwrap();
1549
1550 let content = std::fs::read_to_string(&path).unwrap();
1551 assert!(content.contains("acme.atlassian.net"));
1552 assert!(content.contains("me@acme.com"));
1553 assert!(content.contains("secret"));
1554 assert!(!content.contains("auth_type"));
1556 }
1557
1558 #[test]
1559 fn write_profile_to_config_creates_named_pat_profile() {
1560 let dir = TempDir::new().unwrap();
1561 let path = dir.path().join("config.toml");
1562
1563 write_profile_to_config(&path, "dc", "jira.corp.com", None, "pattoken", "pat", 2).unwrap();
1564
1565 let content = std::fs::read_to_string(&path).unwrap();
1566 assert!(content.contains("[profiles.dc]"));
1567 assert!(content.contains("jira.corp.com"));
1568 assert!(content.contains("pattoken"));
1569 assert!(content.contains("auth_type"));
1570 assert!(content.contains("api_version"));
1571 assert!(!content.contains("email"));
1572 }
1573
1574 #[test]
1575 fn write_profile_to_config_preserves_other_profiles() {
1576 let dir = TempDir::new().unwrap();
1577 let path = dir.path().join("config.toml");
1578
1579 std::fs::write(
1581 &path,
1582 "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
1583 )
1584 .unwrap();
1585
1586 write_profile_to_config(
1588 &path,
1589 "work",
1590 "work.atlassian.net",
1591 Some("w@work.com"),
1592 "tok2",
1593 "basic",
1594 3,
1595 )
1596 .unwrap();
1597
1598 let content = std::fs::read_to_string(&path).unwrap();
1599 assert!(
1600 content.contains("first.atlassian.net"),
1601 "default profile must be preserved"
1602 );
1603 assert!(
1604 content.contains("work.atlassian.net"),
1605 "new profile must be written"
1606 );
1607 }
1608
1609 #[test]
1612 fn remove_profile_removes_default_section() {
1613 let _env = ProcessEnvLock::acquire().unwrap();
1614 let dir = TempDir::new().unwrap();
1615 let path = write_config(
1616 dir.path(),
1617 "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
1618 )
1619 .unwrap();
1620
1621 let _config_dir = set_config_dir_env(dir.path());
1622 remove_profile(&OutputConfig::new(true, false, true), "default").unwrap();
1623
1624 let content = std::fs::read_to_string(&path).unwrap();
1625 assert!(!content.contains("[default]"));
1626 assert!(!content.contains("acme.atlassian.net"));
1627 }
1628
1629 #[test]
1630 fn remove_profile_removes_named_profile_preserves_others() {
1631 let _env = ProcessEnvLock::acquire().unwrap();
1632 let dir = TempDir::new().unwrap();
1633 let path = write_config(
1634 dir.path(),
1635 "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
1636 [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
1637 )
1638 .unwrap();
1639
1640 let _config_dir = set_config_dir_env(dir.path());
1641 remove_profile(&OutputConfig::new(true, false, true), "work").unwrap();
1642
1643 let content = std::fs::read_to_string(&path).unwrap();
1644 assert!(
1645 !content.contains("work.atlassian.net"),
1646 "work profile must be gone"
1647 );
1648 assert!(
1649 content.contains("first.atlassian.net"),
1650 "default profile must be preserved"
1651 );
1652 }
1653
1654 #[test]
1655 fn remove_profile_last_named_profile_leaves_default_intact() {
1656 let _env = ProcessEnvLock::acquire().unwrap();
1657 let dir = TempDir::new().unwrap();
1658 let path = write_config(
1659 dir.path(),
1660 "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
1661 [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
1662 )
1663 .unwrap();
1664
1665 let _config_dir = set_config_dir_env(dir.path());
1666 remove_profile(&OutputConfig::new(true, false, true), "staging").unwrap();
1667
1668 let content = std::fs::read_to_string(&path).unwrap();
1669 assert!(
1670 !content.contains("staging.atlassian.net"),
1671 "staging must be gone"
1672 );
1673 assert!(
1674 content.contains("acme.atlassian.net"),
1675 "default must be preserved"
1676 );
1677 }
1678
1679 #[test]
1682 fn dc_pat_url_without_host_returns_placeholder() {
1683 let url = dc_pat_url(None);
1684 assert!(url.starts_with("http://<your-host>"));
1685 assert!(url.contains(PAT_PATH));
1686 }
1687
1688 #[test]
1689 fn dc_pat_url_bare_host_adds_https_scheme() {
1690 let url = dc_pat_url(Some("jira.corp.com"));
1691 assert!(url.starts_with("https://jira.corp.com"));
1692 assert!(url.contains(PAT_PATH));
1693 }
1694
1695 #[test]
1696 fn dc_pat_url_host_with_https_scheme_is_preserved() {
1697 let url = dc_pat_url(Some("https://jira.corp.com/"));
1698 assert!(url.starts_with("https://jira.corp.com"));
1699 assert!(!url.contains("https://https://"));
1700 assert!(url.contains(PAT_PATH));
1701 }
1702
1703 #[test]
1704 fn dc_pat_url_host_with_http_scheme_is_preserved() {
1705 let url = dc_pat_url(Some("http://localhost:8080"));
1706 assert!(url.starts_with("http://localhost:8080"));
1707 assert!(url.contains(PAT_PATH));
1708 }
1709}