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 credential_store: Option<String>,
16 pub cloud_id: Option<String>,
17 pub token_kind: Option<String>,
18 pub expires_at: Option<String>,
19 pub auth_type: Option<String>,
20 pub api_version: Option<u8>,
21 pub read_only: Option<bool>,
22}
23
24#[derive(Debug, Deserialize, Default)]
25struct RawConfig {
26 #[serde(default)]
27 active_profile: Option<String>,
28 #[serde(default)]
29 default: ProfileConfig,
30 #[serde(default)]
31 profiles: BTreeMap<String, ProfileConfig>,
32 host: Option<String>,
33 email: Option<String>,
34 token: Option<String>,
35 credential_store: Option<String>,
36 cloud_id: Option<String>,
37 token_kind: Option<String>,
38 expires_at: Option<String>,
39 auth_type: Option<String>,
40 api_version: Option<u8>,
41 read_only: Option<bool>,
42}
43
44impl RawConfig {
45 fn default_profile(&self) -> ProfileConfig {
46 ProfileConfig {
47 host: self.default.host.clone().or_else(|| self.host.clone()),
48 email: self.default.email.clone().or_else(|| self.email.clone()),
49 token: self.default.token.clone().or_else(|| self.token.clone()),
50 credential_store: self
51 .default
52 .credential_store
53 .clone()
54 .or_else(|| self.credential_store.clone()),
55 cloud_id: self
56 .default
57 .cloud_id
58 .clone()
59 .or_else(|| self.cloud_id.clone()),
60 token_kind: self
61 .default
62 .token_kind
63 .clone()
64 .or_else(|| self.token_kind.clone()),
65 expires_at: self
66 .default
67 .expires_at
68 .clone()
69 .or_else(|| self.expires_at.clone()),
70 auth_type: self
71 .default
72 .auth_type
73 .clone()
74 .or_else(|| self.auth_type.clone()),
75 api_version: self.default.api_version.or(self.api_version),
76 read_only: self.default.read_only.or(self.read_only),
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct Config {
84 pub profile: String,
85 pub host: String,
86 pub email: String,
87 pub token: String,
88 pub auth_type: AuthType,
89 pub api_version: u8,
90 pub read_only: bool,
91 pub credential_store: String,
92 pub cloud_id: Option<String>,
93 pub token_kind: String,
94 pub expires_at: Option<String>,
95}
96
97impl Config {
98 pub fn load(
104 host_arg: Option<String>,
105 email_arg: Option<String>,
106 profile_arg: Option<String>,
107 ) -> Result<Self, ApiError> {
108 let (profile, file_profile) = load_file_profile(profile_arg.as_deref())?;
109
110 let host = normalize_value(host_arg)
111 .or_else(|| env_var("JIRA_HOST"))
112 .or_else(|| normalize_value(file_profile.host))
113 .ok_or_else(|| {
114 ApiError::InvalidInput(
115 "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
116 )
117 })?;
118
119 let env_token = env_var("JIRA_TOKEN");
120 let stored_token = match file_profile.credential_store.as_deref() {
121 Some("keyring") if env_token.is_none() => crate::credentials::load_optional(&profile)?,
122 Some("file") | None => normalize_value(file_profile.token.clone()),
123 Some(other) => {
124 return Err(ApiError::InvalidInput(format!(
125 "unsupported credential_store `{other}` for profile `{profile}`"
126 )));
127 }
128 };
129 let credential_store = if env_token.is_some() {
130 "environment"
131 } else if file_profile.credential_store.as_deref() == Some("keyring") {
132 "os-keychain"
133 } else if stored_token.is_some() {
134 if file_profile.credential_store.as_deref() == Some("file") {
135 "config-file"
136 } else {
137 "legacy-config"
138 }
139 } else {
140 "none"
141 }
142 .to_string();
143 let token = env_token.or(stored_token).ok_or_else(|| {
144 ApiError::InvalidInput(
145 "No API token configured. Set JIRA_TOKEN or run `jira auth login`.".into(),
146 )
147 })?;
148
149 let auth_type = match env_var("JIRA_AUTH_TYPE")
152 .or_else(|| normalize_value(file_profile.auth_type.clone()))
153 {
154 Some(v) => parse_auth_type(&v)?,
155 None => AuthType::default(),
156 };
157
158 let api_version = match env_var("JIRA_API_VERSION") {
159 Some(v) => parse_api_version(&v)?,
160 None => match file_profile.api_version {
161 Some(v) => validate_api_version(v)?,
162 None => 3,
163 },
164 };
165
166 let email = normalize_value(email_arg)
168 .or_else(|| env_var("JIRA_EMAIL"))
169 .or_else(|| normalize_value(file_profile.email));
170
171 let email = match auth_type {
172 AuthType::Basic => email.ok_or_else(|| {
173 ApiError::InvalidInput(
174 "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
175 )
176 })?,
177 AuthType::Pat => email.unwrap_or_default(),
178 };
179
180 let read_only = match env_var("JIRA_READ_ONLY") {
181 Some(v) => parse_read_only(&v)?,
182 None => file_profile.read_only.unwrap_or(false),
183 };
184
185 let cloud_id = env_var("JIRA_CLOUD_ID").or(file_profile.cloud_id);
186 let token_kind = env_var("JIRA_TOKEN_KIND")
187 .or(file_profile.token_kind)
188 .unwrap_or_else(|| "classic".into());
189 if !matches!(token_kind.as_str(), "classic" | "scoped") {
190 return Err(ApiError::InvalidInput(format!(
191 "unsupported token_kind `{token_kind}`; expected classic or scoped"
192 )));
193 }
194 if token_kind == "scoped" && cloud_id.is_none() {
195 return Err(ApiError::InvalidInput(
196 "scoped Cloud token requires cloud_id; run `jira auth login` again".into(),
197 ));
198 }
199 let expires_at = file_profile.expires_at;
200 if let Some(value) = expires_at.as_deref() {
201 chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
202 ApiError::InvalidInput(format!("invalid expires_at `{value}`; expected YYYY-MM-DD"))
203 })?;
204 }
205
206 Ok(Self {
207 profile,
208 host,
209 email,
210 token,
211 auth_type,
212 api_version,
213 read_only,
214 credential_store,
215 cloud_id,
216 token_kind,
217 expires_at,
218 })
219 }
220}
221
222fn format_available(names: &[&str]) -> String {
226 if names.is_empty() {
227 "none defined".to_string()
228 } else {
229 names.join(", ")
230 }
231}
232
233pub fn config_path() -> PathBuf {
234 config_dir()
235 .unwrap_or_else(|| PathBuf::from(".config"))
236 .join("jira")
237 .join("config.toml")
238}
239
240pub fn schema_config_path() -> String {
241 config_path().display().to_string()
242}
243
244pub fn schema_config_path_description() -> &'static str {
245 #[cfg(target_os = "windows")]
246 {
247 "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
248 }
249
250 #[cfg(not(target_os = "windows"))]
251 {
252 "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
253 }
254}
255
256pub fn recommended_permissions(path: &std::path::Path) -> String {
257 #[cfg(target_os = "windows")]
258 {
259 format!(
260 "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
261 path.display()
262 )
263 }
264
265 #[cfg(not(target_os = "windows"))]
266 {
267 format!("chmod 600 {}", path.display())
268 }
269}
270
271pub fn schema_recommended_permissions_example() -> &'static str {
272 #[cfg(target_os = "windows")]
273 {
274 "Keep the file in your per-user %APPDATA% directory and out of shared folders."
275 }
276
277 #[cfg(not(target_os = "windows"))]
278 {
279 "chmod 600 /path/to/config.toml"
280 }
281}
282
283pub fn schema_dc_pat_url_example() -> String {
288 dc_pat_url(None)
289}
290
291fn config_dir() -> Option<PathBuf> {
292 #[cfg(target_os = "windows")]
293 {
294 dirs::config_dir()
295 }
296
297 #[cfg(not(target_os = "windows"))]
298 {
299 std::env::var_os("XDG_CONFIG_HOME")
300 .filter(|value| !value.is_empty())
301 .map(PathBuf::from)
302 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
303 }
304}
305
306fn load_file_profile(profile: Option<&str>) -> Result<(String, ProfileConfig), ApiError> {
307 let path = config_path();
308 let content = match std::fs::read_to_string(&path) {
309 Ok(c) => c,
310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
311 return Ok((
312 normalize_str(profile)
313 .map(str::to_owned)
314 .or_else(|| env_var("JIRA_PROFILE"))
315 .unwrap_or_else(|| "default".into()),
316 ProfileConfig::default(),
317 ));
318 }
319 Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
320 };
321
322 let raw: RawConfig = toml::from_str(&content)
323 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
324
325 let profile_name = normalize_str(profile)
326 .map(str::to_owned)
327 .or_else(|| env_var("JIRA_PROFILE"))
328 .or_else(|| raw.active_profile.clone());
329
330 match profile_name {
331 Some(name) if name == "default" => Ok((name, raw.default_profile())),
332 Some(name) => {
333 let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
335 raw.profiles
336 .get(&name)
337 .cloned()
338 .map(|value| (name.clone(), value))
339 .ok_or_else(|| {
340 ApiError::NotFound(format!(
341 "profile '{name}' in config. Available: {}",
342 format_available(&available)
343 ))
344 })
345 }
346 None => Ok(("default".into(), raw.default_profile())),
347 }
348}
349
350pub fn show(
352 out: &OutputConfig,
353 host_arg: Option<String>,
354 email_arg: Option<String>,
355 profile_arg: Option<String>,
356) -> Result<(), ApiError> {
357 let path = config_path();
358 let cfg = Config::load(host_arg, email_arg, profile_arg)?;
359 let masked = mask_token(&cfg.token);
360
361 if out.json {
362 out.print_data(
363 &serde_json::to_string_pretty(&serde_json::json!({
364 "configPath": path,
365 "host": cfg.host,
366 "email": cfg.email,
367 "tokenMasked": masked,
368 "profile": cfg.profile,
369 "credentialStore": cfg.credential_store,
370 "tokenKind": cfg.token_kind,
371 "cloudId": cfg.cloud_id,
372 "expiresAt": cfg.expires_at,
373 "expirationStatus": expiration_status(cfg.expires_at.as_deref()),
374 }))
375 .expect("failed to serialize JSON"),
376 );
377 } else {
378 out.print_message(&format!("Config file: {}", path.display()));
379 out.print_data(&format!(
380 "profile: {}\nhost: {}\nemail: {}\ntoken: {masked}\ncredential store: {}\ntoken kind: {}{}",
381 cfg.profile,
382 cfg.host,
383 cfg.email,
384 cfg.credential_store,
385 cfg.token_kind,
386 cfg.expires_at
387 .as_deref()
388 .map(|date| format!("\nexpires: {date}"))
389 .unwrap_or_default()
390 ));
391 }
392 Ok(())
393}
394
395pub async fn auth_status(
396 out: &OutputConfig,
397 host_arg: Option<String>,
398 email_arg: Option<String>,
399 profile_arg: Option<String>,
400 offline: bool,
401) -> Result<(), ApiError> {
402 let cfg = Config::load(host_arg, email_arg, profile_arg)?;
403 if offline {
404 out.print_result(
405 &serde_json::json!({
406 "profile": cfg.profile,
407 "status": "configured",
408 "verified": false,
409 "credentialStore": cfg.credential_store,
410 "tokenKind": cfg.token_kind,
411 "cloudId": cfg.cloud_id,
412 "expiresAt": cfg.expires_at,
413 "expirationStatus": expiration_status(cfg.expires_at.as_deref()),
414 }),
415 &format!(
416 "{} Profile '{}' is configured ({}, {}; network not checked)",
417 sym_ok(),
418 cfg.profile,
419 cfg.credential_store,
420 cfg.token_kind
421 ),
422 );
423 return Ok(());
424 }
425 let client = crate::api::client::JiraClient::new_with_cloud(
426 &cfg.host,
427 &cfg.email,
428 &cfg.token,
429 cfg.auth_type.clone(),
430 cfg.api_version,
431 cfg.cloud_id.as_deref(),
432 &cfg.token_kind,
433 )?;
434 let myself = client.get_myself().await?;
435 out.print_result(
436 &serde_json::json!({
437 "profile": cfg.profile,
438 "status": "ok",
439 "verified": true,
440 "identity": myself.display_name,
441 "credentialStore": cfg.credential_store,
442 "tokenKind": cfg.token_kind,
443 "cloudId": cfg.cloud_id,
444 "expiresAt": cfg.expires_at,
445 "expirationStatus": expiration_status(cfg.expires_at.as_deref()),
446 }),
447 &format!(
448 "{} Authenticated as {} ({}, {}; token {})",
449 sym_ok(),
450 myself.display_name,
451 cfg.credential_store,
452 cfg.token_kind,
453 expiration_status(cfg.expires_at.as_deref())
454 ),
455 );
456 Ok(())
457}
458
459fn expiration_status(expires_at: Option<&str>) -> &'static str {
460 let Some(expires_at) = expires_at else {
461 return "unknown";
462 };
463 let Ok(date) = chrono::NaiveDate::parse_from_str(expires_at, "%Y-%m-%d") else {
464 return "invalid";
465 };
466 let days = date
467 .signed_duration_since(chrono::Utc::now().date_naive())
468 .num_days();
469 if days < 0 {
470 "expired"
471 } else if days <= 30 {
472 "expiring-soon"
473 } else {
474 "valid"
475 }
476}
477
478pub async fn migrate_credential(
479 out: &OutputConfig,
480 profile_arg: Option<String>,
481) -> Result<(), ApiError> {
482 let cfg = Config::load(None, None, profile_arg)?;
483 if cfg.credential_store != "legacy-config" && cfg.credential_store != "config-file" {
484 return Err(ApiError::InvalidInput(format!(
485 "profile `{}` does not contain an inline token to migrate",
486 cfg.profile
487 )));
488 }
489 crate::api::client::JiraClient::new_with_cloud(
490 &cfg.host,
491 &cfg.email,
492 &cfg.token,
493 cfg.auth_type.clone(),
494 cfg.api_version,
495 cfg.cloud_id.as_deref(),
496 &cfg.token_kind,
497 )?
498 .get_myself()
499 .await?;
500
501 crate::credentials::available()?;
502 let previous = crate::credentials::load_optional(&cfg.profile)?;
503 crate::credentials::store(&cfg.profile, &cfg.token)?;
504 if let Err(error) = rewrite_profile_credential(&cfg.profile, Some("keyring")) {
505 match previous {
506 Some(token) => {
507 let _ = crate::credentials::store(&cfg.profile, &token);
508 }
509 None => {
510 let _ = crate::credentials::delete(&cfg.profile);
511 }
512 }
513 return Err(error);
514 }
515 out.print_result(
516 &serde_json::json!({
517 "profile": cfg.profile,
518 "migrated": true,
519 "credentialStore": "os-keychain",
520 }),
521 &format!(
522 "{} Migrated profile `{}` to the operating-system keychain",
523 sym_ok(),
524 cfg.profile
525 ),
526 );
527 Ok(())
528}
529
530pub fn logout(out: &OutputConfig, profile_arg: Option<String>) -> Result<(), ApiError> {
531 let profile = requested_profile_name(profile_arg.as_deref());
532 let (_, stored) = load_file_profile(Some(&profile))?;
533 let removed = if stored.credential_store.as_deref() == Some("keyring") {
534 crate::credentials::delete(&profile)?
535 } else {
536 false
537 };
538 rewrite_profile_credential(&profile, None)?;
539 out.print_result(
540 &serde_json::json!({ "profile": profile, "loggedOut": true, "credentialRemoved": removed }),
541 &format!("{} Logged out profile `{profile}`", sym_ok()),
542 );
543 Ok(())
544}
545
546pub async fn init(
553 out: &OutputConfig,
554 host: Option<&str>,
555 profile: Option<&str>,
556) -> Result<(), ApiError> {
557 if out.json {
558 init_json(out, host);
559 return Ok(());
560 }
561
562 use std::io::IsTerminal;
563 if !std::io::stdin().is_terminal() {
564 return Err(ApiError::InvalidInput(
565 "interactive setup requires a terminal; run `jira init --json` for setup instructions, or configure JIRA_HOST, JIRA_EMAIL, and JIRA_TOKEN for automation"
566 .into(),
567 ));
568 }
569
570 init_interactive(host, profile)
571 .await
572 .map_err(|error| ApiError::Other(error.to_string()))
573}
574
575pub fn schema_example_config() -> serde_json::Value {
582 serde_json::json!({
583 "default": {
584 "host": "mycompany.atlassian.net",
585 "email": "me@example.com",
586 "credential_store": "keyring",
587 "cloud_id": "your-atlassian-cloud-id",
588 "token_kind": "scoped",
589 "expires_at": "2026-11-24",
590 "auth_type": "basic",
591 "api_version": 3,
592 "read_only": true,
593 },
594 "profiles": {
595 "work": {
596 "host": "work.atlassian.net",
597 "email": "me@work.com",
598 "credential_store": "keyring",
599 "cloud_id": "your-work-cloud-id",
600 "token_kind": "scoped",
601 },
602 "datacenter": {
603 "host": "jira.mycompany.com",
604 "credential_store": "keyring",
605 "expires_at": "2026-11-24",
606 "auth_type": "pat",
607 "api_version": 2,
608 }
609 }
610 })
611}
612
613fn init_json(out: &OutputConfig, host: Option<&str>) {
614 let path = config_path();
615 let path_resolution = schema_config_path_description();
616 let permission_advice = recommended_permissions(&path);
617 let example = schema_example_config();
618
619 const CLOUD_TOKEN_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
620 let pat_url = dc_pat_url(host);
621
622 out.print_data(
623 &serde_json::to_string_pretty(&serde_json::json!({
624 "configPath": path,
625 "pathResolution": path_resolution,
626 "configExists": path.exists(),
627 "tokenInstructions": CLOUD_TOKEN_URL,
628 "dcPatInstructions": pat_url,
629 "recommendedPermissions": permission_advice,
630 "example": example,
631 }))
632 .expect("failed to serialize JSON"),
633 );
634}
635
636async fn init_interactive(
637 prefill_host: Option<&str>,
638 requested_profile: Option<&str>,
639) -> Result<(), Box<dyn std::error::Error>> {
640 let sep = sym_dim("──────────────");
641 eprintln!("Jira CLI Setup");
642 eprintln!("{sep}");
643
644 let path = config_path();
645
646 let (target_name, existing): (Option<String>, Option<ProfileConfig>) =
652 if let Some(name) = requested_profile {
653 let existing = if path.exists() {
654 let profiles = list_profile_names(&path)?;
655 profiles
656 .iter()
657 .any(|candidate| candidate == name)
658 .then(|| read_raw_profile(&path, name))
659 .transpose()?
660 } else {
661 None
662 };
663 (Some(name.to_owned()), existing)
664 } else if path.exists() {
665 let profiles = list_profile_names(&path)?;
666
667 eprintln!();
670 eprintln!(
671 " {} {}",
672 sym_dim("Config:"),
673 sym_dim(&path.display().to_string())
674 );
675 eprintln!();
676 eprintln!(" {}:", sym_dim("Profiles"));
677 for name in &profiles {
678 let host = read_raw_profile(&path, name)
679 .ok()
680 .and_then(|p| p.host)
681 .unwrap_or_default();
682 eprintln!(" {} {} {}", sym_dim("•"), name, sym_dim(&host));
683 }
684 eprintln!();
685
686 let action = prompt("Action", "[update/add]", Some("update"))?;
687 eprintln!();
688
689 if !action.trim().eq_ignore_ascii_case("add") {
690 let default = profiles.first().map(String::as_str).unwrap_or("default");
691 let raw = if profiles.len() > 1 {
692 prompt("Profile", "", Some(default))?
693 } else {
694 default.to_owned()
695 };
696 let name = if raw.trim().is_empty() {
697 default.to_owned()
698 } else {
699 raw.trim().to_owned()
700 };
701 let cfg = read_raw_profile(&path, &name)?;
702 if profiles.len() > 1 {
703 eprintln!();
704 }
705 (Some(name), Some(cfg))
706 } else {
707 (None, None)
708 }
709 } else {
710 eprintln!();
712 (Some("default".to_owned()), None)
713 };
714
715 let is_cloud = if let Some(ref p) = existing {
717 p.auth_type.as_deref() != Some("pat")
718 } else {
719 let t = prompt("Type", sym_dim("[cloud/dc]").as_str(), Some("cloud"))?;
720 eprintln!();
721 !t.trim().eq_ignore_ascii_case("dc")
722 };
723
724 let host = if is_cloud {
726 let default_sub = existing
727 .as_ref()
728 .and_then(|p| p.host.clone())
729 .as_deref()
730 .or(prefill_host)
731 .map(|h| h.trim_end_matches(".atlassian.net").to_owned());
732 let raw = prompt_required("Subdomain", "", default_sub.as_deref())?;
733 let sub = raw.trim().trim_end_matches(".atlassian.net");
734 format!("{sub}.atlassian.net")
735 } else {
736 let default = existing
737 .as_ref()
738 .and_then(|p| p.host.clone())
739 .or_else(|| prefill_host.map(str::to_owned));
740 prompt_required("Host", "", default.as_deref())?
741 };
742
743 let prior_token = match (
744 existing
745 .as_ref()
746 .and_then(|profile| profile.credential_store.as_deref()),
747 target_name.as_deref(),
748 ) {
749 (Some("keyring"), Some(name)) => crate::credentials::load_optional(name)?,
750 _ => existing
751 .as_ref()
752 .and_then(|profile| profile.token.clone())
753 .filter(|token| !token.trim().is_empty()),
754 };
755
756 let (email, token, auth_type, api_version, cloud_id, token_kind, expires_at): (
758 Option<String>,
759 String,
760 &str,
761 u8,
762 Option<String>,
763 String,
764 Option<String>,
765 ) = if is_cloud {
766 const CLOUD_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
767 let default_email = existing.as_ref().and_then(|p| p.email.clone());
768 let email = prompt_required("Email", "", default_email.as_deref())?;
769 let default_kind = existing
770 .as_ref()
771 .and_then(|profile| profile.token_kind.as_deref())
772 .unwrap_or("scoped");
773 let requested_kind = prompt("Token type", "[scoped/classic]", Some(default_kind))?;
774 let token_kind = if requested_kind.eq_ignore_ascii_case("classic") {
775 "classic".to_owned()
776 } else {
777 "scoped".to_owned()
778 };
779 let cloud_id = if token_kind == "scoped" {
780 eprint!(" Discovering Cloud ID...");
781 std::io::stderr().flush().ok();
782 let id = discover_cloud_id(&host).await?;
783 eprintln!(" {}", sym_ok());
784 Some(id)
785 } else {
786 None
787 };
788 if prior_token.is_none()
789 && prompt_bool("Open Atlassian's token page now?", true)?
790 && let Err(error) = open::that(CLOUD_URL)
791 {
792 eprintln!(" {} Could not open browser: {error}", sym_fail());
793 }
794 eprintln!(" {}", sym_dim(&format!("→ {CLOUD_URL}")));
795 if token_kind == "scoped" {
796 eprintln!(
797 " {}",
798 sym_dim("Choose Jira scopes and the least privilege needed for this profile.")
799 );
800 }
801 let token_hint = if prior_token.is_some() {
802 "(Enter to keep)"
803 } else {
804 ""
805 };
806 let raw = prompt_secret("Token", token_hint)?;
807 let kept_existing = raw.trim().is_empty();
808 let token = if kept_existing {
809 prior_token
810 .clone()
811 .ok_or("No existing token. Please enter a token.")?
812 } else {
813 raw
814 };
815 let expires_at = if kept_existing {
816 existing
817 .as_ref()
818 .and_then(|profile| profile.expires_at.clone())
819 } else {
820 Some(prompt_expiration_date(90)?)
821 };
822 (
823 Some(email),
824 token,
825 "basic",
826 3,
827 cloud_id,
828 token_kind,
829 expires_at,
830 )
831 } else {
832 let pat_url = dc_pat_url(Some(&host));
833 let (token, expires_at) = if let Some(existing_token) = prior_token.clone() {
834 print_dc_pat_link(&pat_url);
835 let raw = prompt_secret("Personal access token", "(Enter to keep)")?;
836 if raw.trim().is_empty() {
837 (
838 existing_token,
839 existing
840 .as_ref()
841 .and_then(|profile| profile.expires_at.clone()),
842 )
843 } else {
844 (raw, Some(prompt_expiration_date(90)?))
845 }
846 } else if prompt_bool("Create a dedicated PAT automatically?", true)? {
847 let method = prompt("Bootstrap with", "[password/pat]", Some("password"))?;
848 let use_pat = method.eq_ignore_ascii_case("pat");
849 let username = if use_pat {
850 None
851 } else {
852 Some(prompt_required("Bootstrap username", "", None)?)
853 };
854 let secret = prompt_secret(
855 if use_pat {
856 "Existing personal access token"
857 } else {
858 "Bootstrap password"
859 },
860 "used once and never saved",
861 )?;
862 let expiration_days = prompt_expiration_days(90)?;
863 eprint!(" Creating personal access token...");
864 std::io::stderr().flush().ok();
865 match create_data_center_pat(
866 &host,
867 username.as_deref(),
868 &secret,
869 target_name.as_deref().unwrap_or("jira-cli"),
870 expiration_days,
871 )
872 .await
873 {
874 Ok(token) => {
875 eprintln!(" {}", sym_ok());
876 (token, Some(expiration_date(expiration_days)))
877 }
878 Err(error) => {
879 eprintln!(" {} {error}", sym_fail());
880 eprintln!(" Falling back to browser-assisted PAT creation.");
881 let _ = open::that(&pat_url);
882 print_dc_pat_link(&pat_url);
883 (
884 prompt_secret("Personal access token", "")?,
885 Some(prompt_expiration_date(90)?),
886 )
887 }
888 }
889 } else {
890 let _ = open::that(&pat_url);
891 print_dc_pat_link(&pat_url);
892 (
893 prompt_secret("Personal access token", "")?,
894 Some(prompt_expiration_date(90)?),
895 )
896 };
897 let default_ver = existing
898 .as_ref()
899 .and_then(|p| p.api_version.map(|v| v.to_string()))
900 .unwrap_or_else(|| "2".to_owned());
901 let ver_str = prompt("API version", "", Some(&default_ver))?;
902 let api_version: u8 = ver_str.trim().parse().unwrap_or(2);
903 (
904 None,
905 token,
906 "pat",
907 api_version,
908 None,
909 "classic".to_owned(),
910 expires_at,
911 )
912 };
913
914 let default_read_only = existing
915 .as_ref()
916 .and_then(|profile| profile.read_only)
917 .unwrap_or(false);
918 let read_only = prompt_bool("Read-only mode?", default_read_only)?;
919
920 use std::io::Write;
922 eprintln!();
923 eprint!(" Verifying credentials...");
924 std::io::stderr().flush().ok();
925
926 let auth_type_enum = if auth_type == "pat" {
927 AuthType::Pat
928 } else {
929 AuthType::Basic
930 };
931
932 let verified = match crate::api::client::JiraClient::new_with_cloud(
933 &host,
934 email.as_deref().unwrap_or(""),
935 &token,
936 auth_type_enum,
937 api_version,
938 cloud_id.as_deref(),
939 &token_kind,
940 ) {
941 Err(e) => {
942 eprintln!(" {} {e}", sym_fail());
943 return Err(e.into());
944 }
945 Ok(client) => match client.get_myself().await {
946 Ok(myself) => {
947 eprintln!(" {} Authenticated as {}", sym_ok(), myself.display_name);
948 true
949 }
950 Err(e) => {
951 eprintln!(" {} {e}", sym_fail());
952 eprintln!();
953 let save = prompt("Save config anyway?", sym_dim("[y/N]").as_str(), Some("n"))?;
954 save.trim().eq_ignore_ascii_case("y")
955 }
956 },
957 };
958
959 if !verified {
960 eprintln!();
961 eprintln!("{sep}");
962 return Ok(());
963 }
964
965 let profile_name = match target_name {
967 Some(name) => name,
968 None => {
969 eprintln!();
970 let raw = prompt_required("Profile name", "", Some("default"))?;
971 if raw.trim().is_empty() {
972 "default".to_owned()
973 } else {
974 raw.trim().to_owned()
975 }
976 }
977 };
978
979 let file_storage = choose_credential_storage()?;
980 let previous_keyring = if file_storage {
981 None
982 } else {
983 crate::credentials::load_optional(&profile_name)?
984 };
985 if !file_storage {
986 crate::credentials::store(&profile_name, &token)?;
987 }
988
989 let write_result = write_profile_to_config(
992 &path,
993 &profile_name,
994 ProfileWrite {
995 host: &host,
996 email: email.as_deref(),
997 token: &token,
998 credential_store: if file_storage { "file" } else { "keyring" },
999 cloud_id: cloud_id.as_deref(),
1000 token_kind: &token_kind,
1001 expires_at: expires_at.as_deref(),
1002 auth_type,
1003 api_version,
1004 read_only,
1005 },
1006 );
1007 if let Err(error) = write_result {
1008 if !file_storage {
1009 match previous_keyring {
1010 Some(previous) => {
1011 let _ = crate::credentials::store(&profile_name, &previous);
1012 }
1013 None => {
1014 let _ = crate::credentials::delete(&profile_name);
1015 }
1016 }
1017 }
1018 return Err(error);
1019 }
1020 if file_storage {
1021 let _ = crate::credentials::delete(&profile_name);
1022 }
1023
1024 #[cfg(unix)]
1025 {
1026 use std::os::unix::fs::PermissionsExt;
1027 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
1028 }
1029
1030 eprintln!();
1031 eprintln!(" {} Config written to {}", sym_ok(), path.display());
1032 eprintln!(
1033 " {}",
1034 sym_dim(if file_storage {
1035 "Credential storage: protected config file; treat it as a secret"
1036 } else {
1037 "Credential storage: operating-system keychain"
1038 })
1039 );
1040 eprintln!("{sep}");
1041 if profile_name == "default" {
1042 eprintln!(" Run: jira projects list");
1043 } else {
1044 eprintln!(" Run: jira --profile {profile_name} projects list");
1045 }
1046 eprintln!();
1047
1048 Ok(())
1049}
1050
1051fn list_profile_names(path: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1053 let content = std::fs::read_to_string(path)?;
1054 let doc: toml::Value = toml::from_str(&content)?;
1055 let table = doc.as_table().ok_or("config is not a TOML table")?;
1056
1057 let mut names = Vec::new();
1058 if table.contains_key("default") {
1059 names.push("default".to_owned());
1060 }
1061 if let Some(profiles) = table.get("profiles").and_then(toml::Value::as_table) {
1062 for name in profiles.keys() {
1063 names.push(name.clone());
1064 }
1065 }
1066 Ok(names)
1067}
1068
1069fn read_raw_profile(
1071 path: &std::path::Path,
1072 name: &str,
1073) -> Result<ProfileConfig, Box<dyn std::error::Error>> {
1074 let content = std::fs::read_to_string(path)?;
1075 let raw: RawConfig = toml::from_str(&content)?;
1076 if name == "default" {
1077 Ok(raw.default_profile())
1078 } else {
1079 Ok(raw.profiles.get(name).cloned().unwrap_or_default())
1080 }
1081}
1082
1083fn prompt(label: &str, hint: &str, default: Option<&str>) -> Result<String, std::io::Error> {
1088 use std::io::{self, Write};
1089 let hint_part = if hint.is_empty() {
1090 String::new()
1091 } else {
1092 format!(" {hint}")
1093 };
1094 let default_part = match default {
1095 Some(d) if !d.is_empty() => format!(" [{d}]"),
1096 _ => String::new(),
1097 };
1098 eprint!("{} {label}{hint_part}{default_part}: ", sym_q());
1099 io::stderr().flush()?;
1100 let mut buf = String::new();
1101 io::stdin().read_line(&mut buf)?;
1102 let trimmed = buf.trim().to_owned();
1103 if trimmed.is_empty() {
1104 Ok(default.unwrap_or("").to_owned())
1105 } else {
1106 Ok(trimmed)
1107 }
1108}
1109
1110fn prompt_required(
1112 label: &str,
1113 hint: &str,
1114 default: Option<&str>,
1115) -> Result<String, std::io::Error> {
1116 loop {
1117 let value = prompt(label, hint, default)?;
1118 if !value.trim().is_empty() {
1119 return Ok(value);
1120 }
1121 eprintln!(" {} {label} is required.", sym_fail());
1122 }
1123}
1124
1125fn prompt_secret(label: &str, hint: &str) -> Result<String, std::io::Error> {
1127 use std::io::{self, Write};
1128 let hint_part = if hint.is_empty() {
1129 String::new()
1130 } else {
1131 format!(" {hint}")
1132 };
1133 eprint!("{} {label}{hint_part}: ", sym_q());
1134 io::stderr().flush()?;
1135 rpassword::read_password().map(|value| value.trim().to_owned())
1136}
1137
1138fn prompt_bool(label: &str, default: bool) -> Result<bool, std::io::Error> {
1139 let default_value = if default { "y" } else { "n" };
1140 let value = prompt(label, "[y/n]", Some(default_value))?;
1141 Ok(match value.to_ascii_lowercase().as_str() {
1142 "y" | "yes" | "true" | "1" => true,
1143 "n" | "no" | "false" | "0" => false,
1144 _ => default,
1145 })
1146}
1147
1148fn prompt_expiration_days(default: u64) -> Result<u64, Box<dyn std::error::Error>> {
1149 loop {
1150 let value = prompt(
1151 "Token expiry in days",
1152 "[1-365]",
1153 Some(&default.to_string()),
1154 )?;
1155 match value.parse::<u64>() {
1156 Ok(days @ 1..=365) => return Ok(days),
1157 _ => eprintln!(" {} Expiry must be between 1 and 365 days.", sym_fail()),
1158 }
1159 }
1160}
1161
1162fn expiration_date(days: u64) -> String {
1163 (chrono::Utc::now() + chrono::Duration::days(days as i64))
1164 .date_naive()
1165 .to_string()
1166}
1167
1168fn prompt_expiration_date(default: u64) -> Result<String, Box<dyn std::error::Error>> {
1169 Ok(expiration_date(prompt_expiration_days(default)?))
1170}
1171
1172fn choose_credential_storage() -> Result<bool, Box<dyn std::error::Error>> {
1173 match crate::credentials::available() {
1174 Ok(()) => Ok(false),
1175 Err(error) => {
1176 eprintln!(" {} {error}", sym_fail());
1177 if prompt_bool("Use the protected config-file fallback instead?", false)? {
1178 Ok(true)
1179 } else {
1180 Err("credential storage cancelled; start an OS credential service or use JIRA_TOKEN for this session".into())
1181 }
1182 }
1183 }
1184}
1185
1186async fn discover_cloud_id(host: &str) -> Result<String, Box<dyn std::error::Error>> {
1187 #[derive(Deserialize)]
1188 #[serde(rename_all = "camelCase")]
1189 struct TenantInfo {
1190 cloud_id: String,
1191 }
1192
1193 let site = if host.starts_with("http://") || host.starts_with("https://") {
1194 host.trim_end_matches('/').to_owned()
1195 } else {
1196 format!("https://{}", host.trim_end_matches('/'))
1197 };
1198 let info = reqwest::Client::new()
1199 .get(format!("{site}/_edge/tenant_info"))
1200 .send()
1201 .await?
1202 .error_for_status()?
1203 .json::<TenantInfo>()
1204 .await?;
1205 if info.cloud_id.trim().is_empty() {
1206 return Err("Atlassian returned an empty Cloud ID".into());
1207 }
1208 Ok(info.cloud_id)
1209}
1210
1211async fn create_data_center_pat(
1212 host: &str,
1213 username: Option<&str>,
1214 bootstrap_secret: &str,
1215 profile_name: &str,
1216 expiration_days: u64,
1217) -> Result<String, Box<dyn std::error::Error>> {
1218 let site = if host.starts_with("http://") || host.starts_with("https://") {
1219 host.trim_end_matches('/').to_owned()
1220 } else {
1221 format!("https://{}", host.trim_end_matches('/'))
1222 };
1223 let request = reqwest::Client::new()
1224 .post(format!("{site}/rest/pat/latest/tokens"))
1225 .json(&serde_json::json!({
1226 "name": format!("jira-cli / {profile_name}"),
1227 "expirationDuration": expiration_days,
1228 }));
1229 let request = match username {
1230 Some(username) => request.basic_auth(username, Some(bootstrap_secret)),
1231 None => request.bearer_auth(bootstrap_secret),
1232 };
1233 let response = request.send().await?;
1234 let status = response.status();
1235 if !status.is_success() {
1236 return Err(format!("PAT creation failed with HTTP {status}").into());
1237 }
1238 let body: serde_json::Value = response.json().await?;
1239 ["rawToken", "token"]
1240 .into_iter()
1241 .find_map(|field| body.get(field).and_then(serde_json::Value::as_str))
1242 .filter(|token| !token.is_empty())
1243 .map(str::to_owned)
1244 .ok_or_else(|| "PAT creation response did not contain the one-time token".into())
1245}
1246
1247fn sym_q() -> String {
1250 if crate::output::use_color() {
1251 use owo_colors::OwoColorize;
1252 "?".green().bold().to_string()
1253 } else {
1254 "?".to_owned()
1255 }
1256}
1257
1258fn sym_ok() -> String {
1259 if crate::output::use_color() {
1260 use owo_colors::OwoColorize;
1261 "✔".green().to_string()
1262 } else {
1263 "✔".to_owned()
1264 }
1265}
1266
1267fn sym_fail() -> String {
1268 if crate::output::use_color() {
1269 use owo_colors::OwoColorize;
1270 "✖".red().to_string()
1271 } else {
1272 "✖".to_owned()
1273 }
1274}
1275
1276fn sym_dim(s: &str) -> String {
1277 if crate::output::use_color() {
1278 use owo_colors::OwoColorize;
1279 s.dimmed().to_string()
1280 } else {
1281 s.to_owned()
1282 }
1283}
1284
1285struct ProfileWrite<'a> {
1290 host: &'a str,
1291 email: Option<&'a str>,
1292 token: &'a str,
1293 credential_store: &'a str,
1294 cloud_id: Option<&'a str>,
1295 token_kind: &'a str,
1296 expires_at: Option<&'a str>,
1297 auth_type: &'a str,
1298 api_version: u8,
1299 read_only: bool,
1300}
1301
1302fn write_profile_to_config(
1303 path: &std::path::Path,
1304 profile_name: &str,
1305 profile: ProfileWrite<'_>,
1306) -> Result<(), Box<dyn std::error::Error>> {
1307 let existing = if path.exists() {
1308 std::fs::read_to_string(path)?
1309 } else {
1310 String::new()
1311 };
1312
1313 let mut doc: toml::Value = if existing.trim().is_empty() {
1314 toml::Value::Table(toml::map::Map::new())
1315 } else {
1316 toml::from_str(&existing)?
1317 };
1318
1319 let root = doc.as_table_mut().expect("config is a TOML table");
1320 root.insert(
1321 "active_profile".to_owned(),
1322 toml::Value::String(profile_name.to_owned()),
1323 );
1324
1325 let mut section = toml::map::Map::new();
1326 section.insert(
1327 "host".to_owned(),
1328 toml::Value::String(profile.host.to_owned()),
1329 );
1330 if let Some(e) = profile.email {
1331 section.insert("email".to_owned(), toml::Value::String(e.to_owned()));
1332 }
1333 section.insert(
1334 "credential_store".to_owned(),
1335 toml::Value::String(profile.credential_store.to_owned()),
1336 );
1337 if profile.credential_store == "file" {
1338 section.insert(
1339 "token".to_owned(),
1340 toml::Value::String(profile.token.to_owned()),
1341 );
1342 }
1343 if let Some(cloud_id) = profile.cloud_id {
1344 section.insert(
1345 "cloud_id".to_owned(),
1346 toml::Value::String(cloud_id.to_owned()),
1347 );
1348 }
1349 if profile.token_kind != "classic" {
1350 section.insert(
1351 "token_kind".to_owned(),
1352 toml::Value::String(profile.token_kind.to_owned()),
1353 );
1354 }
1355 if let Some(expires_at) = profile.expires_at {
1356 section.insert(
1357 "expires_at".to_owned(),
1358 toml::Value::String(expires_at.to_owned()),
1359 );
1360 }
1361 if profile.auth_type != "basic" {
1362 section.insert(
1363 "auth_type".to_owned(),
1364 toml::Value::String(profile.auth_type.to_owned()),
1365 );
1366 section.insert(
1367 "api_version".to_owned(),
1368 toml::Value::Integer(i64::from(profile.api_version)),
1369 );
1370 }
1371 if profile.read_only {
1372 section.insert("read_only".to_owned(), toml::Value::Boolean(true));
1373 }
1374
1375 if profile_name == "default" {
1376 root.insert("default".to_owned(), toml::Value::Table(section));
1377 } else {
1378 let profiles = root
1379 .entry("profiles")
1380 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
1381 let profiles = profiles.as_table_mut().ok_or_else(|| {
1385 format!(
1386 "{} defines `profiles` as something other than a table, so the `{profile_name}` profile cannot be added to it",
1387 path.display()
1388 )
1389 })?;
1390 profiles.insert(profile_name.to_owned(), toml::Value::Table(section));
1391 }
1392
1393 if let Some(parent) = path.parent() {
1394 std::fs::create_dir_all(parent)?;
1395 }
1396 let body = toml::to_string_pretty(&doc)?;
1397 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
1398 let mut temp = tempfile::Builder::new()
1399 .prefix(".config-")
1400 .suffix(".toml.tmp")
1401 .tempfile_in(parent)?;
1402 use std::io::Write;
1403 temp.write_all(body.as_bytes())?;
1404 temp.flush()?;
1405 #[cfg(unix)]
1406 {
1407 use std::os::unix::fs::PermissionsExt;
1408 std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
1409 }
1410 temp.persist(path).map_err(|error| error.error)?;
1411
1412 Ok(())
1413}
1414
1415fn requested_profile_name(profile: Option<&str>) -> String {
1421 normalize_str(profile)
1422 .map(str::to_owned)
1423 .or_else(|| env_var("JIRA_PROFILE"))
1424 .unwrap_or_else(|| "default".into())
1425}
1426
1427fn profile_table_mut<'a>(
1428 root: &'a mut toml::Table,
1429 profile_name: &str,
1430) -> Result<&'a mut toml::Table, ApiError> {
1431 if profile_name == "default" {
1432 if root.contains_key("default") {
1433 return root
1434 .get_mut("default")
1435 .and_then(toml::Value::as_table_mut)
1436 .ok_or_else(|| ApiError::Other("`default` is not a TOML table".into()));
1437 }
1438 return Ok(root);
1439 }
1440 root.get_mut("profiles")
1441 .and_then(toml::Value::as_table_mut)
1442 .and_then(|profiles| profiles.get_mut(profile_name))
1443 .and_then(toml::Value::as_table_mut)
1444 .ok_or_else(|| ApiError::NotFound(format!("profile `{profile_name}` in config")))
1445}
1446
1447fn write_toml_atomically(path: &std::path::Path, doc: &toml::Value) -> Result<(), ApiError> {
1448 let body = toml::to_string_pretty(doc)
1449 .map_err(|error| ApiError::Other(format!("Failed to serialize config: {error}")))?;
1450 let parent = path
1451 .parent()
1452 .ok_or_else(|| ApiError::Other("config path has no parent".into()))?;
1453 std::fs::create_dir_all(parent)
1454 .map_err(|error| ApiError::Other(format!("Failed to create config directory: {error}")))?;
1455 let mut temp = tempfile::Builder::new()
1456 .prefix(".config-")
1457 .suffix(".toml.tmp")
1458 .tempfile_in(parent)
1459 .map_err(|error| ApiError::Other(format!("Failed to create temporary config: {error}")))?;
1460 use std::io::Write;
1461 temp.write_all(body.as_bytes())
1462 .and_then(|()| temp.flush())
1463 .map_err(|error| ApiError::Other(format!("Failed to write temporary config: {error}")))?;
1464 #[cfg(unix)]
1465 {
1466 use std::os::unix::fs::PermissionsExt;
1467 std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))
1468 .map_err(|error| ApiError::Other(format!("Failed to protect config: {error}")))?;
1469 }
1470 temp.persist(path)
1471 .map_err(|error| ApiError::Other(format!("Failed to replace config: {}", error.error)))?;
1472 Ok(())
1473}
1474
1475fn rewrite_profile_credential(
1476 profile_name: &str,
1477 credential_store: Option<&str>,
1478) -> Result<(), ApiError> {
1479 let path = config_path();
1480 let content = std::fs::read_to_string(&path)
1481 .map_err(|error| ApiError::Other(format!("Failed to read config: {error}")))?;
1482 let mut doc: toml::Value = toml::from_str(&content)
1483 .map_err(|error| ApiError::Other(format!("Failed to parse config: {error}")))?;
1484 let root = doc
1485 .as_table_mut()
1486 .ok_or_else(|| ApiError::Other("config is not a TOML table".into()))?;
1487 let profile = profile_table_mut(root, profile_name)?;
1488 profile.remove("token");
1489 match credential_store {
1490 Some(store) => {
1491 profile.insert("credential_store".into(), toml::Value::String(store.into()));
1492 }
1493 None => {
1494 profile.remove("credential_store");
1495 }
1496 }
1497 write_toml_atomically(&path, &doc)
1498}
1499
1500pub fn remove_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
1501 let path = config_path();
1502
1503 if !path.exists() {
1504 return Err(ApiError::NotFound(format!(
1505 "config file at {}",
1506 path.display()
1507 )));
1508 }
1509
1510 let content = std::fs::read_to_string(&path)
1511 .map_err(|e| ApiError::Other(format!("Failed to read config: {e}")))?;
1512 let mut doc: toml::Value = toml::from_str(&content)
1513 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
1514 let root = doc
1515 .as_table_mut()
1516 .ok_or_else(|| ApiError::Other("config is not a TOML table".to_string()))?;
1517
1518 let removed = if profile_name == "default" {
1519 root.remove("default").is_some()
1520 } else {
1521 root.get_mut("profiles")
1522 .and_then(toml::Value::as_table_mut)
1523 .and_then(|t| t.remove(profile_name))
1524 .is_some()
1525 };
1526
1527 if !removed {
1528 return Err(ApiError::NotFound(format!(
1529 "profile '{profile_name}' in config. Available: {}",
1530 format_available(&removable_profiles(root))
1531 )));
1532 }
1533
1534 if root.get("active_profile").and_then(toml::Value::as_str) == Some(profile_name) {
1535 let next = removable_profiles(root).first().copied().map(str::to_owned);
1536 match next {
1537 Some(name) => {
1538 root.insert("active_profile".into(), toml::Value::String(name));
1539 }
1540 None => {
1541 root.remove("active_profile");
1542 }
1543 }
1544 }
1545
1546 write_toml_atomically(&path, &doc)?;
1547 let _ = crate::credentials::delete(profile_name);
1548
1549 out.print_result(
1550 &serde_json::json!({ "profile": profile_name, "removed": true }),
1551 &format!("{} Removed profile '{profile_name}'", sym_ok()),
1552 );
1553 Ok(())
1554}
1555
1556pub fn print_config_path(out: &OutputConfig) -> Result<(), ApiError> {
1557 let path = config_path();
1558 out.print_result(
1559 &serde_json::json!({ "configPath": path }),
1560 &path.display().to_string(),
1561 );
1562 Ok(())
1563}
1564
1565pub fn list_profiles(out: &OutputConfig) -> Result<(), ApiError> {
1566 let path = config_path();
1567 let content = std::fs::read_to_string(&path)
1568 .map_err(|error| ApiError::Other(format!("Failed to read config: {error}")))?;
1569 let raw: RawConfig = toml::from_str(&content)
1570 .map_err(|error| ApiError::Other(format!("Failed to parse config: {error}")))?;
1571 let active = raw.active_profile.as_deref().unwrap_or("default");
1572 let mut items = Vec::new();
1573 if raw.default.host.is_some() || raw.host.is_some() {
1574 let profile = raw.default_profile();
1575 items.push(serde_json::json!({
1576 "name": "default", "host": profile.host, "active": active == "default"
1577 }));
1578 }
1579 items.extend(raw.profiles.iter().map(|(name, profile)| {
1580 serde_json::json!({
1581 "name": name, "host": profile.host, "active": active == name
1582 })
1583 }));
1584 if out.json {
1585 out.print_data(
1586 &serde_json::to_string_pretty(
1587 &serde_json::json!({"items": items, "total": items.len()}),
1588 )
1589 .expect("failed to serialize profiles"),
1590 );
1591 } else if items.is_empty() {
1592 out.print_data("No profiles configured. Run `jira init`.");
1593 } else {
1594 for item in items {
1595 let marker = if item["active"].as_bool().unwrap_or(false) {
1596 "*"
1597 } else {
1598 " "
1599 };
1600 out.print_data(&format!(
1601 "{marker} {:<20} {}",
1602 item["name"].as_str().unwrap_or_default(),
1603 item["host"].as_str().unwrap_or("-")
1604 ));
1605 }
1606 }
1607 Ok(())
1608}
1609
1610pub fn use_profile(out: &OutputConfig, profile_name: &str) -> Result<(), ApiError> {
1611 let path = config_path();
1612 let content = std::fs::read_to_string(&path)
1613 .map_err(|error| ApiError::Other(format!("Failed to read config: {error}")))?;
1614 let mut doc: toml::Value = toml::from_str(&content)
1615 .map_err(|error| ApiError::Other(format!("Failed to parse config: {error}")))?;
1616 let root = doc
1617 .as_table_mut()
1618 .ok_or_else(|| ApiError::Other("config is not a TOML table".into()))?;
1619 if !removable_profiles(root).contains(&profile_name) {
1620 return Err(ApiError::NotFound(format!(
1621 "profile '{profile_name}' in config"
1622 )));
1623 }
1624 root.insert(
1625 "active_profile".into(),
1626 toml::Value::String(profile_name.into()),
1627 );
1628 write_toml_atomically(&path, &doc)?;
1629 out.print_result(
1630 &serde_json::json!({"profile": profile_name, "active": true}),
1631 &format!("{} Active profile set to '{profile_name}'", sym_ok()),
1632 );
1633 Ok(())
1634}
1635
1636fn removable_profiles(root: &toml::Table) -> Vec<&str> {
1639 let mut names: Vec<&str> = Vec::new();
1640 if root.contains_key("default") {
1641 names.push("default");
1642 }
1643 if let Some(profiles) = root.get("profiles").and_then(toml::Value::as_table) {
1644 names.extend(profiles.keys().map(String::as_str));
1645 }
1646 names
1647}
1648
1649const PAT_PATH: &str = "/secure/ViewProfile.jspa";
1652const PAT_NAVIGATION: &str = "Profile → Personal access tokens";
1653
1654fn dc_pat_url(host: Option<&str>) -> String {
1659 match host {
1660 Some(h) => {
1661 let base = if h.starts_with("http://") || h.starts_with("https://") {
1662 h.trim_end_matches('/').to_string()
1663 } else {
1664 format!("https://{}", h.trim_end_matches('/'))
1665 };
1666 format!("{base}{PAT_PATH}")
1667 }
1668 None => format!("http://<your-host>{PAT_PATH}"),
1669 }
1670}
1671
1672fn print_dc_pat_link(url: &str) {
1673 eprintln!(" {}", sym_dim(&format!("→ {url}")));
1674 eprintln!(" {}", sym_dim(PAT_NAVIGATION));
1675}
1676
1677fn mask_token(token: &str) -> String {
1682 let n = token.chars().count();
1683 if n > 4 {
1684 let suffix: String = token.chars().skip(n - 4).collect();
1685 format!("***{suffix}")
1686 } else {
1687 "***".into()
1688 }
1689}
1690
1691fn env_var(name: &str) -> Option<String> {
1692 std::env::var(name)
1693 .ok()
1694 .and_then(|value| normalize_value(Some(value)))
1695}
1696
1697pub const TRUTHY: &[&str] = &["1", "true", "yes", "on"];
1703pub const FALSY: &[&str] = &["0", "false", "no", "off"];
1704
1705pub fn is_truthy(value: &str) -> bool {
1711 TRUTHY.contains(&value.trim().to_ascii_lowercase().as_str())
1712}
1713
1714fn parse_read_only(value: &str) -> Result<bool, ApiError> {
1722 let v = value.to_ascii_lowercase();
1723 if TRUTHY.contains(&v.as_str()) {
1724 Ok(true)
1725 } else if FALSY.contains(&v.as_str()) {
1726 Ok(false)
1727 } else {
1728 Err(ApiError::InvalidInput(format!(
1729 "JIRA_READ_ONLY is set to '{value}', which is neither on ({}) nor off ({}). \
1730 Refusing to run rather than guess whether writes are meant to be blocked.",
1731 TRUTHY.join(", "),
1732 FALSY.join(", ")
1733 )))
1734 }
1735}
1736
1737fn parse_auth_type(value: &str) -> Result<AuthType, ApiError> {
1742 if value.eq_ignore_ascii_case("basic") {
1743 Ok(AuthType::Basic)
1744 } else if value.eq_ignore_ascii_case("pat") {
1745 Ok(AuthType::Pat)
1746 } else {
1747 Err(ApiError::InvalidInput(format!(
1748 "auth_type '{value}' is not recognised. Use 'basic' (Jira Cloud) or \
1749 'pat' (Jira Data Center/Server)."
1750 )))
1751 }
1752}
1753
1754const API_VERSIONS: &[u8] = &[2, 3];
1756
1757fn parse_api_version(value: &str) -> Result<u8, ApiError> {
1758 let parsed = value.parse::<u8>().map_err(|_| {
1759 ApiError::InvalidInput(format!(
1760 "api_version '{value}' is not a number. Use 3 (Jira Cloud) or 2 \
1761 (Jira Data Center/Server)."
1762 ))
1763 })?;
1764 validate_api_version(parsed)
1765}
1766
1767fn validate_api_version(version: u8) -> Result<u8, ApiError> {
1770 if API_VERSIONS.contains(&version) {
1771 Ok(version)
1772 } else {
1773 Err(ApiError::InvalidInput(format!(
1774 "api_version {version} is not supported. Use 3 (Jira Cloud) or 2 \
1775 (Jira Data Center/Server)."
1776 )))
1777 }
1778}
1779
1780fn normalize_value(value: Option<String>) -> Option<String> {
1781 value.and_then(|value| {
1782 let trimmed = value.trim();
1783 if trimmed.is_empty() {
1784 None
1785 } else {
1786 Some(trimmed.to_string())
1787 }
1788 })
1789}
1790
1791fn normalize_str(value: Option<&str>) -> Option<&str> {
1792 value.and_then(|value| {
1793 let trimmed = value.trim();
1794 if trimmed.is_empty() {
1795 None
1796 } else {
1797 Some(trimmed)
1798 }
1799 })
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804 use super::*;
1805 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
1806 use tempfile::TempDir;
1807
1808 #[test]
1809 fn mask_token_long() {
1810 let masked = mask_token("ATATxxx1234abcd");
1811 assert!(masked.starts_with("***"));
1812 assert!(masked.ends_with("abcd"));
1813 }
1814
1815 #[test]
1816 fn read_only_accepts_its_documented_values_in_any_case() {
1817 for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", "On"] {
1818 assert!(parse_read_only(on).unwrap(), "{on} should enable the guard");
1819 }
1820 for off in ["0", "false", "FALSE", "no", "No", "off", "OFF"] {
1821 assert!(
1822 !parse_read_only(off).unwrap(),
1823 "{off} should disable the guard"
1824 );
1825 }
1826 }
1827
1828 #[test]
1831 fn read_only_refuses_a_value_it_does_not_understand() {
1832 for bad in ["enabled", "ture", "2", "y", "readonly"] {
1833 let err = parse_read_only(bad).unwrap_err();
1834 let message = err.to_string();
1835 assert!(
1836 message.contains(bad),
1837 "the rejection must quote the offending value; got: {message}"
1838 );
1839 assert!(
1840 matches!(err, ApiError::InvalidInput(_)),
1841 "{bad} must be reported as bad input, not as a Jira failure"
1842 );
1843 }
1844 }
1845
1846 #[test]
1849 fn is_truthy_accepts_any_case_and_treats_the_unknown_as_off() {
1850 for on in ["1", "true", "TRUE", "True", "yes", "YES", "on", " on "] {
1851 assert!(is_truthy(on), "{on} should read as on");
1852 }
1853 for off in ["0", "false", "no", "off", "enabled", "ture", ""] {
1854 assert!(!is_truthy(off), "{off} should read as off");
1855 }
1856 }
1857
1858 #[test]
1861 fn load_blank_auth_type_in_the_config_file_is_treated_as_unset() {
1862 let _lock = ProcessEnvLock::acquire();
1863 let dir = TempDir::new().unwrap();
1864 write_config(
1865 dir.path(),
1866 "[default]\nhost = \"x.atlassian.net\"\nemail = \"me@example.com\"\n\
1867 token = \"t\"\nauth_type = \" \"\n",
1868 )
1869 .unwrap();
1870 let _config_dir = set_config_dir_env(dir.path());
1871 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1872 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
1873
1874 let config = Config::load(None, None, None).unwrap();
1875 assert_eq!(config.auth_type, AuthType::Basic);
1876 }
1877
1878 #[test]
1879 fn auth_type_refuses_a_typo_rather_than_falling_back_to_basic() {
1880 assert_eq!(parse_auth_type("pat").unwrap(), AuthType::Pat);
1881 assert_eq!(parse_auth_type("PAT").unwrap(), AuthType::Pat);
1882 assert_eq!(parse_auth_type("basic").unwrap(), AuthType::Basic);
1883
1884 let err = parse_auth_type("ptt").unwrap_err().to_string();
1885 assert!(err.contains("ptt"), "got: {err}");
1886 assert!(
1887 err.contains("pat"),
1888 "the message must name the real spelling"
1889 );
1890 }
1891
1892 #[test]
1893 fn api_version_refuses_anything_the_client_cannot_address() {
1894 assert_eq!(parse_api_version("2").unwrap(), 2);
1895 assert_eq!(parse_api_version("3").unwrap(), 3);
1896
1897 for bad in ["v3", "", "3.0", "latest"] {
1898 assert!(
1899 parse_api_version(bad).is_err(),
1900 "{bad} is not a version number"
1901 );
1902 }
1903 let err = parse_api_version("7").unwrap_err().to_string();
1905 assert!(err.contains('7'), "got: {err}");
1906 }
1907
1908 #[test]
1909 fn mask_token_short() {
1910 assert_eq!(mask_token("abc"), "***");
1911 }
1912
1913 #[test]
1914 fn mask_token_unicode_safe() {
1915 let token = "token-日本語-end";
1917 let result = mask_token(token);
1918 assert!(result.starts_with("***"));
1919 }
1920
1921 #[test]
1922 #[cfg(not(target_os = "windows"))]
1923 fn config_path_prefers_xdg_config_home() {
1924 let _env = ProcessEnvLock::acquire().unwrap();
1925 let dir = TempDir::new().unwrap();
1926 let _config_dir = set_config_dir_env(dir.path());
1927
1928 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
1929 }
1930
1931 #[test]
1932 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
1933 let _env = ProcessEnvLock::acquire().unwrap();
1934 let dir = TempDir::new().unwrap();
1935 write_config(
1936 dir.path(),
1937 r#"
1938[default]
1939host = "work.atlassian.net"
1940email = "me@example.com"
1941token = "secret-token"
1942"#,
1943 )
1944 .unwrap();
1945
1946 let _config_dir = set_config_dir_env(dir.path());
1947 let _host = EnvVarGuard::set("JIRA_HOST", " ");
1948 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1949 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
1950 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1951
1952 let cfg = Config::load(None, None, None).unwrap();
1953 assert_eq!(cfg.host, "work.atlassian.net");
1954 assert_eq!(cfg.email, "me@example.com");
1955 assert_eq!(cfg.token, "secret-token");
1956 }
1957
1958 #[test]
1959 fn load_accepts_documented_default_section() {
1960 let _env = ProcessEnvLock::acquire().unwrap();
1961 let dir = TempDir::new().unwrap();
1962 write_config(
1963 dir.path(),
1964 r#"
1965[default]
1966host = "example.atlassian.net"
1967email = "me@example.com"
1968token = "secret-token"
1969"#,
1970 )
1971 .unwrap();
1972
1973 let _config_dir = set_config_dir_env(dir.path());
1974 let _host = EnvVarGuard::unset("JIRA_HOST");
1975 let _email = EnvVarGuard::unset("JIRA_EMAIL");
1976 let _token = EnvVarGuard::unset("JIRA_TOKEN");
1977 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1978
1979 let cfg = Config::load(None, None, None).unwrap();
1980 assert_eq!(cfg.host, "example.atlassian.net");
1981 assert_eq!(cfg.email, "me@example.com");
1982 assert_eq!(cfg.token, "secret-token");
1983 }
1984
1985 #[test]
1986 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
1987 let _env = ProcessEnvLock::acquire().unwrap();
1988 let dir = TempDir::new().unwrap();
1989 let _config_dir = set_config_dir_env(dir.path());
1990 let _host = EnvVarGuard::set("JIRA_HOST", "");
1991 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
1992 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
1993 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
1994
1995 let err = Config::load(None, None, None).unwrap_err();
1996 assert!(matches!(err, ApiError::InvalidInput(_)));
1997 assert!(err.to_string().contains("No Jira host configured"));
1998 }
1999
2000 #[test]
2001 fn permission_guidance_matches_platform() {
2002 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
2003
2004 #[cfg(target_os = "windows")]
2005 assert!(guidance.contains("AppData"));
2006
2007 #[cfg(not(target_os = "windows"))]
2008 assert!(guidance.starts_with("chmod 600 "));
2009 }
2010
2011 #[test]
2014 fn load_env_host_overrides_file() {
2015 let _env = ProcessEnvLock::acquire().unwrap();
2016 let dir = TempDir::new().unwrap();
2017 write_config(
2018 dir.path(),
2019 r#"
2020[default]
2021host = "file.atlassian.net"
2022email = "me@example.com"
2023token = "tok"
2024"#,
2025 )
2026 .unwrap();
2027
2028 let _config_dir = set_config_dir_env(dir.path());
2029 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
2030 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2031 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2032 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2033
2034 let cfg = Config::load(None, None, None).unwrap();
2035 assert_eq!(cfg.host, "env.atlassian.net");
2036 }
2037
2038 #[test]
2039 fn load_cli_host_arg_overrides_env_and_file() {
2040 let _env = ProcessEnvLock::acquire().unwrap();
2041 let dir = TempDir::new().unwrap();
2042 write_config(
2043 dir.path(),
2044 r#"
2045[default]
2046host = "file.atlassian.net"
2047email = "me@example.com"
2048token = "tok"
2049"#,
2050 )
2051 .unwrap();
2052
2053 let _config_dir = set_config_dir_env(dir.path());
2054 let _host = EnvVarGuard::set("JIRA_HOST", "env.atlassian.net");
2055 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2056 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2057 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2058
2059 let cfg = Config::load(Some("cli.atlassian.net".into()), None, None).unwrap();
2060 assert_eq!(cfg.host, "cli.atlassian.net");
2061 }
2062
2063 #[test]
2066 fn load_missing_token_returns_error() {
2067 let _env = ProcessEnvLock::acquire().unwrap();
2068 let dir = TempDir::new().unwrap();
2069 let _config_dir = set_config_dir_env(dir.path());
2070 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
2071 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
2072 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2073 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2074
2075 let err = Config::load(None, None, None).unwrap_err();
2076 assert!(matches!(err, ApiError::InvalidInput(_)));
2077 assert!(err.to_string().contains("No API token"));
2078 }
2079
2080 #[test]
2081 fn load_missing_email_for_basic_auth_returns_error() {
2082 let _env = ProcessEnvLock::acquire().unwrap();
2083 let dir = TempDir::new().unwrap();
2084 let _config_dir = set_config_dir_env(dir.path());
2085 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
2086 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2087 let _token = EnvVarGuard::set("JIRA_TOKEN", "secret");
2088 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
2089 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2090
2091 let err = Config::load(None, None, None).unwrap_err();
2092 assert!(matches!(err, ApiError::InvalidInput(_)));
2093 assert!(err.to_string().contains("No email configured"));
2094 }
2095
2096 #[test]
2097 fn load_invalid_toml_returns_error() {
2098 let _env = ProcessEnvLock::acquire().unwrap();
2099 let dir = TempDir::new().unwrap();
2100 write_config(dir.path(), "host = [invalid toml").unwrap();
2101
2102 let _config_dir = set_config_dir_env(dir.path());
2103 let _host = EnvVarGuard::unset("JIRA_HOST");
2104 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2105 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2106
2107 let err = Config::load(None, None, None).unwrap_err();
2108 assert!(matches!(err, ApiError::Other(_)));
2109 assert!(err.to_string().contains("parse"));
2110 }
2111
2112 #[test]
2115 fn load_pat_auth_does_not_require_email() {
2116 let _env = ProcessEnvLock::acquire().unwrap();
2117 let dir = TempDir::new().unwrap();
2118 write_config(
2119 dir.path(),
2120 r#"
2121[default]
2122host = "jira.corp.com"
2123token = "my-pat-token"
2124auth_type = "pat"
2125api_version = 2
2126"#,
2127 )
2128 .unwrap();
2129
2130 let _config_dir = set_config_dir_env(dir.path());
2131 let _host = EnvVarGuard::unset("JIRA_HOST");
2132 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2133 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2134 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
2135 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2136
2137 let cfg = Config::load(None, None, None).unwrap();
2138 assert_eq!(cfg.auth_type, AuthType::Pat);
2139 assert_eq!(cfg.api_version, 2);
2140 assert!(cfg.email.is_empty(), "PAT auth sets email to empty string");
2141 }
2142
2143 #[test]
2144 fn load_jira_auth_type_env_pat_overrides_basic() {
2145 let _env = ProcessEnvLock::acquire().unwrap();
2146 let dir = TempDir::new().unwrap();
2147 write_config(
2148 dir.path(),
2149 r#"
2150[default]
2151host = "jira.corp.com"
2152email = "me@example.com"
2153token = "tok"
2154auth_type = "basic"
2155"#,
2156 )
2157 .unwrap();
2158
2159 let _config_dir = set_config_dir_env(dir.path());
2160 let _host = EnvVarGuard::unset("JIRA_HOST");
2161 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2162 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2163 let _auth = EnvVarGuard::set("JIRA_AUTH_TYPE", "pat");
2164 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2165
2166 let cfg = Config::load(None, None, None).unwrap();
2167 assert_eq!(cfg.auth_type, AuthType::Pat);
2168 }
2169
2170 #[test]
2171 fn load_jira_api_version_env_overrides_default() {
2172 let _env = ProcessEnvLock::acquire().unwrap();
2173 let dir = TempDir::new().unwrap();
2174 let _config_dir = set_config_dir_env(dir.path());
2175 let _host = EnvVarGuard::set("JIRA_HOST", "myhost.atlassian.net");
2176 let _email = EnvVarGuard::set("JIRA_EMAIL", "me@example.com");
2177 let _token = EnvVarGuard::set("JIRA_TOKEN", "tok");
2178 let _api_version = EnvVarGuard::set("JIRA_API_VERSION", "2");
2179 let _auth = EnvVarGuard::unset("JIRA_AUTH_TYPE");
2180 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2181
2182 let cfg = Config::load(None, None, None).unwrap();
2183 assert_eq!(cfg.api_version, 2);
2184 }
2185
2186 #[test]
2189 fn load_profile_arg_selects_named_section() {
2190 let _env = ProcessEnvLock::acquire().unwrap();
2191 let dir = TempDir::new().unwrap();
2192 write_config(
2193 dir.path(),
2194 r#"
2195[default]
2196host = "default.atlassian.net"
2197email = "default@example.com"
2198token = "default-tok"
2199
2200[profiles.work]
2201host = "work.atlassian.net"
2202email = "me@work.com"
2203token = "work-tok"
2204"#,
2205 )
2206 .unwrap();
2207
2208 let _config_dir = set_config_dir_env(dir.path());
2209 let _host = EnvVarGuard::unset("JIRA_HOST");
2210 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2211 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2212 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2213
2214 let cfg = Config::load(None, None, Some("work".into())).unwrap();
2215 assert_eq!(cfg.host, "work.atlassian.net");
2216 assert_eq!(cfg.email, "me@work.com");
2217 assert_eq!(cfg.token, "work-tok");
2218 }
2219
2220 #[test]
2221 fn load_jira_profile_env_selects_named_section() {
2222 let _env = ProcessEnvLock::acquire().unwrap();
2223 let dir = TempDir::new().unwrap();
2224 write_config(
2225 dir.path(),
2226 r#"
2227[default]
2228host = "default.atlassian.net"
2229email = "default@example.com"
2230token = "default-tok"
2231
2232[profiles.staging]
2233host = "staging.atlassian.net"
2234email = "me@staging.com"
2235token = "staging-tok"
2236"#,
2237 )
2238 .unwrap();
2239
2240 let _config_dir = set_config_dir_env(dir.path());
2241 let _host = EnvVarGuard::unset("JIRA_HOST");
2242 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2243 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2244 let _profile = EnvVarGuard::set("JIRA_PROFILE", "staging");
2245
2246 let cfg = Config::load(None, None, None).unwrap();
2247 assert_eq!(cfg.host, "staging.atlassian.net");
2248 }
2249
2250 #[test]
2251 fn load_uses_active_profile_when_no_override_is_set() {
2252 let _env = ProcessEnvLock::acquire().unwrap();
2253 let dir = TempDir::new().unwrap();
2254 write_config(
2255 dir.path(),
2256 r#"
2257active_profile = "work"
2258
2259[default]
2260host = "default.atlassian.net"
2261email = "default@example.com"
2262token = "default-tok"
2263
2264[profiles.work]
2265host = "work.atlassian.net"
2266email = "me@work.com"
2267token = "work-tok"
2268"#,
2269 )
2270 .unwrap();
2271
2272 let _config_dir = set_config_dir_env(dir.path());
2273 let _host = EnvVarGuard::unset("JIRA_HOST");
2274 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2275 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2276 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2277
2278 let cfg = Config::load(None, None, None).unwrap();
2279 assert_eq!(cfg.profile, "work");
2280 assert_eq!(cfg.host, "work.atlassian.net");
2281 }
2282
2283 #[test]
2284 fn load_unknown_profile_returns_descriptive_error() {
2285 let _env = ProcessEnvLock::acquire().unwrap();
2286 let dir = TempDir::new().unwrap();
2287 write_config(
2288 dir.path(),
2289 r#"
2290[profiles.alpha]
2291host = "alpha.atlassian.net"
2292email = "me@alpha.com"
2293token = "alpha-tok"
2294"#,
2295 )
2296 .unwrap();
2297
2298 let _config_dir = set_config_dir_env(dir.path());
2299 let _host = EnvVarGuard::unset("JIRA_HOST");
2300 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2301 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2302
2303 let err = Config::load(None, None, Some("nonexistent".into())).unwrap_err();
2304 assert!(
2305 matches!(err, ApiError::NotFound(_)),
2306 "selecting a profile that is not in the config is a not_found condition, \
2307 not an unexpected error: {err:?}"
2308 );
2309 let msg = err.to_string();
2310 assert!(
2311 msg.contains("nonexistent"),
2312 "error should name the bad profile"
2313 );
2314 assert!(
2315 msg.contains("alpha"),
2316 "error should list available profiles"
2317 );
2318 }
2319
2320 #[test]
2323 fn show_json_output_includes_host_and_masked_token() {
2324 let _env = ProcessEnvLock::acquire().unwrap();
2325 let dir = TempDir::new().unwrap();
2326 write_config(
2327 dir.path(),
2328 r#"
2329[default]
2330host = "show-test.atlassian.net"
2331email = "me@example.com"
2332token = "supersecrettoken"
2333"#,
2334 )
2335 .unwrap();
2336
2337 let _config_dir = set_config_dir_env(dir.path());
2338 let _host = EnvVarGuard::unset("JIRA_HOST");
2339 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2340 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2341 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2342
2343 let out = crate::output::OutputConfig::new(true, false, true);
2344 show(&out, None, None, None).unwrap();
2346 }
2347
2348 #[test]
2349 fn show_text_output_renders_without_error() {
2350 let _env = ProcessEnvLock::acquire().unwrap();
2351 let dir = TempDir::new().unwrap();
2352 write_config(
2353 dir.path(),
2354 r#"
2355[default]
2356host = "show-test.atlassian.net"
2357email = "me@example.com"
2358token = "supersecrettoken"
2359"#,
2360 )
2361 .unwrap();
2362
2363 let _config_dir = set_config_dir_env(dir.path());
2364 let _host = EnvVarGuard::unset("JIRA_HOST");
2365 let _email = EnvVarGuard::unset("JIRA_EMAIL");
2366 let _token = EnvVarGuard::unset("JIRA_TOKEN");
2367 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
2368
2369 let out = crate::output::OutputConfig::new(false, false, true);
2370 show(&out, None, None, None).unwrap();
2371 }
2372
2373 #[tokio::test]
2376 async fn init_json_output_includes_example_and_paths() {
2377 let out = crate::output::OutputConfig::new(true, false, true);
2378 init(&out, Some("jira.corp.com"), None).await.unwrap();
2380 }
2381
2382 #[tokio::test]
2385 async fn init_non_interactive_returns_actionable_error() {
2386 let out = crate::output::OutputConfig {
2387 json: false,
2388 quiet: false,
2389 };
2390 let error = init(&out, None, None).await.unwrap_err();
2392 assert!(matches!(error, ApiError::InvalidInput(_)));
2393 assert!(error.to_string().contains("JIRA_TOKEN"));
2394 }
2395
2396 #[test]
2397 fn write_profile_to_config_creates_default_profile() {
2398 let dir = TempDir::new().unwrap();
2399 let path = dir.path().join("jira").join("config.toml");
2400
2401 write_profile_to_config(
2402 &path,
2403 "default",
2404 ProfileWrite {
2405 host: "acme.atlassian.net",
2406 email: Some("me@acme.com"),
2407 token: "secret",
2408 credential_store: "file",
2409 cloud_id: None,
2410 token_kind: "classic",
2411 expires_at: None,
2412 auth_type: "basic",
2413 api_version: 3,
2414 read_only: false,
2415 },
2416 )
2417 .unwrap();
2418
2419 let content = std::fs::read_to_string(&path).unwrap();
2420 assert!(content.contains("acme.atlassian.net"));
2421 assert!(content.contains("me@acme.com"));
2422 assert!(content.contains("secret"));
2423 assert!(!content.contains("auth_type"));
2425 }
2426
2427 #[test]
2428 fn write_profile_to_config_creates_named_pat_profile() {
2429 let dir = TempDir::new().unwrap();
2430 let path = dir.path().join("config.toml");
2431
2432 write_profile_to_config(
2433 &path,
2434 "dc",
2435 ProfileWrite {
2436 host: "jira.corp.com",
2437 email: None,
2438 token: "pattoken",
2439 credential_store: "file",
2440 cloud_id: None,
2441 token_kind: "classic",
2442 expires_at: None,
2443 auth_type: "pat",
2444 api_version: 2,
2445 read_only: true,
2446 },
2447 )
2448 .unwrap();
2449
2450 let content = std::fs::read_to_string(&path).unwrap();
2451 assert!(content.contains("[profiles.dc]"));
2452 assert!(content.contains("jira.corp.com"));
2453 assert!(content.contains("pattoken"));
2454 assert!(content.contains("auth_type"));
2455 assert!(content.contains("api_version"));
2456 assert!(content.contains("read_only = true"));
2457 assert!(!content.contains("email"));
2458 }
2459
2460 #[test]
2461 fn a_profiles_key_that_is_not_a_table_is_reported_rather_than_panicked_on() {
2462 let dir = TempDir::new().unwrap();
2463 let path = dir.path().join("config.toml");
2464 std::fs::write(&path, "profiles = 5\n").unwrap();
2465
2466 let err = write_profile_to_config(
2467 &path,
2468 "work",
2469 ProfileWrite {
2470 host: "h.atlassian.net",
2471 email: None,
2472 token: "tok",
2473 credential_store: "file",
2474 cloud_id: None,
2475 token_kind: "classic",
2476 expires_at: None,
2477 auth_type: "basic",
2478 api_version: 3,
2479 read_only: false,
2480 },
2481 )
2482 .expect_err("a `profiles` integer cannot hold a profile")
2483 .to_string();
2484 assert!(
2485 err.contains("profiles") && err.contains("work"),
2486 "the message must name the key and the profile being added: {err}"
2487 );
2488 assert!(
2489 err.contains(&path.display().to_string()),
2490 "the message must name the file to edit: {err}"
2491 );
2492
2493 let good = dir.path().join("good.toml");
2496 std::fs::write(&good, "[profiles.other]\nhost = \"a.b\"\n").unwrap();
2497 write_profile_to_config(
2498 &good,
2499 "work",
2500 ProfileWrite {
2501 host: "h.atlassian.net",
2502 email: None,
2503 token: "tok",
2504 credential_store: "file",
2505 cloud_id: None,
2506 token_kind: "classic",
2507 expires_at: None,
2508 auth_type: "basic",
2509 api_version: 3,
2510 read_only: false,
2511 },
2512 )
2513 .unwrap();
2514 let written = std::fs::read_to_string(&good).unwrap();
2515 assert!(written.contains("[profiles.work]") && written.contains("[profiles.other]"));
2516 }
2517
2518 #[test]
2519 fn write_profile_to_config_preserves_other_profiles() {
2520 let dir = TempDir::new().unwrap();
2521 let path = dir.path().join("config.toml");
2522
2523 std::fs::write(
2525 &path,
2526 "[default]\nhost = \"first.atlassian.net\"\nemail = \"a@b.com\"\ntoken = \"tok1\"\n",
2527 )
2528 .unwrap();
2529
2530 write_profile_to_config(
2532 &path,
2533 "work",
2534 ProfileWrite {
2535 host: "work.atlassian.net",
2536 email: Some("w@work.com"),
2537 token: "tok2",
2538 credential_store: "file",
2539 cloud_id: None,
2540 token_kind: "classic",
2541 expires_at: None,
2542 auth_type: "basic",
2543 api_version: 3,
2544 read_only: false,
2545 },
2546 )
2547 .unwrap();
2548
2549 let content = std::fs::read_to_string(&path).unwrap();
2550 assert!(
2551 content.contains("first.atlassian.net"),
2552 "default profile must be preserved"
2553 );
2554 assert!(
2555 content.contains("work.atlassian.net"),
2556 "new profile must be written"
2557 );
2558 }
2559
2560 #[test]
2563 fn remove_profile_removes_default_section() {
2564 let _env = ProcessEnvLock::acquire().unwrap();
2565 let dir = TempDir::new().unwrap();
2566 let path = write_config(
2567 dir.path(),
2568 "[default]\nhost = \"acme.atlassian.net\"\nemail = \"me@acme.com\"\ntoken = \"tok\"\n",
2569 )
2570 .unwrap();
2571
2572 let _config_dir = set_config_dir_env(dir.path());
2573 remove_profile(&OutputConfig::new(true, false, true), "default").unwrap();
2574
2575 let content = std::fs::read_to_string(&path).unwrap();
2576 assert!(!content.contains("[default]"));
2577 assert!(!content.contains("acme.atlassian.net"));
2578 }
2579
2580 #[test]
2581 fn remove_profile_removes_named_profile_preserves_others() {
2582 let _env = ProcessEnvLock::acquire().unwrap();
2583 let dir = TempDir::new().unwrap();
2584 let path = write_config(
2585 dir.path(),
2586 "[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
2587 [profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
2588 )
2589 .unwrap();
2590
2591 let _config_dir = set_config_dir_env(dir.path());
2592 remove_profile(&OutputConfig::new(true, false, true), "work").unwrap();
2593
2594 let content = std::fs::read_to_string(&path).unwrap();
2595 assert!(
2596 !content.contains("work.atlassian.net"),
2597 "work profile must be gone"
2598 );
2599 assert!(
2600 content.contains("first.atlassian.net"),
2601 "default profile must be preserved"
2602 );
2603 }
2604
2605 #[test]
2606 fn remove_profile_last_named_profile_leaves_default_intact() {
2607 let _env = ProcessEnvLock::acquire().unwrap();
2608 let dir = TempDir::new().unwrap();
2609 let path = write_config(
2610 dir.path(),
2611 "[default]\nhost = \"acme.atlassian.net\"\ntoken = \"tok\"\n\n\
2612 [profiles.staging]\nhost = \"staging.atlassian.net\"\ntoken = \"tok2\"\n",
2613 )
2614 .unwrap();
2615
2616 let _config_dir = set_config_dir_env(dir.path());
2617 remove_profile(&OutputConfig::new(true, false, true), "staging").unwrap();
2618
2619 let content = std::fs::read_to_string(&path).unwrap();
2620 assert!(
2621 !content.contains("staging.atlassian.net"),
2622 "staging must be gone"
2623 );
2624 assert!(
2625 content.contains("acme.atlassian.net"),
2626 "default must be preserved"
2627 );
2628 }
2629
2630 #[test]
2633 fn dc_pat_url_without_host_returns_placeholder() {
2634 let url = dc_pat_url(None);
2635 assert!(url.starts_with("http://<your-host>"));
2636 assert!(url.ends_with(PAT_PATH));
2637 }
2638
2639 #[test]
2640 fn dc_pat_url_bare_host_adds_https_scheme() {
2641 let url = dc_pat_url(Some("jira.corp.com"));
2642 assert!(url.starts_with("https://jira.corp.com"));
2643 assert!(url.ends_with(PAT_PATH));
2644 }
2645
2646 #[test]
2647 fn dc_pat_url_host_with_https_scheme_is_preserved() {
2648 let url = dc_pat_url(Some("https://jira.corp.com/"));
2649 assert!(url.starts_with("https://jira.corp.com"));
2650 assert!(!url.contains("https://https://"));
2651 assert!(url.ends_with(PAT_PATH));
2652 }
2653
2654 #[test]
2655 fn dc_pat_url_host_with_http_scheme_is_preserved() {
2656 let url = dc_pat_url(Some("http://localhost:8080"));
2657 assert!(url.starts_with("http://localhost:8080"));
2658 assert!(url.ends_with(PAT_PATH));
2659 }
2660}