1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6use crate::api::ApiError;
7
8#[derive(Debug, Deserialize, Default, Clone)]
9pub struct ProfileConfig {
10 pub host: Option<String>,
11 pub email: Option<String>,
12 pub token: Option<String>,
13}
14
15#[derive(Debug, Deserialize, Default)]
16struct RawConfig {
17 #[serde(flatten)]
18 default: ProfileConfig,
19 #[serde(default)]
20 profiles: BTreeMap<String, ProfileConfig>,
21}
22
23#[derive(Debug, Clone)]
25pub struct Config {
26 pub host: String,
27 pub email: String,
28 pub token: String,
29}
30
31impl Config {
32 pub fn load(
38 host_arg: Option<String>,
39 email_arg: Option<String>,
40 profile_arg: Option<String>,
41 ) -> Result<Self, ApiError> {
42 let file_profile = load_file_profile(profile_arg.as_deref())?;
43
44 let host = host_arg
45 .or_else(|| std::env::var("JIRA_HOST").ok())
46 .or(file_profile.host)
47 .ok_or_else(|| {
48 ApiError::InvalidInput(
49 "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
50 )
51 })?;
52
53 let email = email_arg
54 .or_else(|| std::env::var("JIRA_EMAIL").ok())
55 .or(file_profile.email)
56 .ok_or_else(|| {
57 ApiError::InvalidInput(
58 "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
59 )
60 })?;
61
62 let token = std::env::var("JIRA_TOKEN")
63 .ok()
64 .or(file_profile.token)
65 .ok_or_else(|| {
66 ApiError::InvalidInput(
67 "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
68 )
69 })?;
70
71 Ok(Self { host, email, token })
72 }
73}
74
75fn config_path() -> PathBuf {
76 dirs::config_dir()
77 .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
78 .unwrap_or_else(|| PathBuf::from(".config"))
79 .join("jira")
80 .join("config.toml")
81}
82
83fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
84 let path = config_path();
85 let content = match std::fs::read_to_string(&path) {
86 Ok(c) => c,
87 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
88 Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
89 };
90
91 let raw: RawConfig = toml::from_str(&content)
92 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
93
94 let profile_name = profile
95 .map(String::from)
96 .or_else(|| std::env::var("JIRA_PROFILE").ok());
97
98 match profile_name {
99 Some(name) => {
100 let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
102 raw.profiles.get(&name).cloned().ok_or_else(|| {
103 ApiError::Other(format!(
104 "Profile '{name}' not found in config. Available: {}",
105 available.join(", ")
106 ))
107 })
108 }
109 None => Ok(raw.default),
110 }
111}
112
113pub fn show(host_arg: Option<String>, email_arg: Option<String>, profile_arg: Option<String>) {
115 let path = config_path();
116 eprintln!("Config file: {}", path.display());
117
118 match Config::load(host_arg, email_arg, profile_arg) {
119 Ok(cfg) => {
120 let masked = mask_token(&cfg.token);
121 println!("host: {}", cfg.host);
122 println!("email: {}", cfg.email);
123 println!("token: {masked}");
124 }
125 Err(e) => {
126 eprintln!("Config error: {e}");
127 }
128 }
129}
130
131pub fn init() {
133 let path = config_path();
134 println!("Create or edit: {}", path.display());
135 println!();
136 println!("Example config:");
137 println!();
138 println!("[default]");
139 println!("host = \"mycompany.atlassian.net\"");
140 println!("email = \"me@example.com\"");
141 println!("token = \"your-api-token\"");
142 println!();
143 println!("# Optional named profiles:");
144 println!("# [profiles.work]");
145 println!("# host = \"work.atlassian.net\"");
146 println!("# email = \"me@work.com\"");
147 println!("# token = \"work-token\"");
148 println!();
149 println!(
150 "Get your API token at: https://id.atlassian.com/manage-profile/security/api-tokens"
151 );
152 println!();
153 println!("Permissions: chmod 600 {}", path.display());
154}
155
156fn mask_token(token: &str) -> String {
161 let n = token.chars().count();
162 if n > 4 {
163 let suffix: String = token.chars().skip(n - 4).collect();
164 format!("***{suffix}")
165 } else {
166 "***".into()
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn mask_token_long() {
176 let masked = mask_token("ATATxxx1234abcd");
177 assert!(masked.starts_with("***"));
178 assert!(masked.ends_with("abcd"));
179 }
180
181 #[test]
182 fn mask_token_short() {
183 assert_eq!(mask_token("abc"), "***");
184 }
185
186 #[test]
187 fn mask_token_unicode_safe() {
188 let token = "token-日本語-end";
190 let result = mask_token(token);
191 assert!(result.starts_with("***"));
192 }
193}